ETL & Data Engineering Interview

Deep Analysis — Complete Question Breakdown with Detailed Answers

Topics: Python, SQL, AWS, ETL, APIs
Questions: 10
Difficulty: Intermediate

Questions at a Glance

Click any card to jump to the detailed answer.


1

Data Validation (S3 + Python + SQL)

Python SQL AWS S3
You receive an Excel/CSV file on S3 bucket. There's a mobile number column. If mobile number length is less than 10 or greater than 10 digits, it should give error. But rest of the records should get processed. For example, 100 records received, 2 have incorrect mobile numbers. How do you write validation in SQL and Python?

The Core Idea

The interviewer wants to know: can you build a pipeline that does not stop on bad data? The key concept is graceful failure — process 98 good records, log 2 bad ones, and alert someone about the errors. Never throw the whole batch away because of a few bad rows.

S3 (raw CSV) │ ▼ Python (pandas) ── read + validate │ ┌──┴──┐ │ │ ▼ ▼ valid invalid records records │ │ ▼ ▼ DB Table Error Table / Error CSV on S3 │ ▼ Alert (email / Slack / SNS)

Approach 1: Python with Pandas

This is the most common approach in real ETL jobs. You read the file from S3, validate each row, split into two DataFrames, and load each into the right place.

Python data_validation.py
import boto3
import pandas as pd
import re
from io import StringIO
from datetime import datetime

# ─── Step 1: Read CSV from S3 ───────────────────────────
s3 = boto3.client('s3')
response = s3.get_object(
    Bucket='my-data-bucket',
    Key='incoming/customers_20260323.csv'
)
csv_content = response['Body'].read().decode('utf-8')
df = pd.read_csv(StringIO(csv_content))

# ─── Step 2: Validate mobile numbers ───────────────────
# Regex: exactly 10 digits, nothing else
mobile_pattern = re.compile(r'^[0-9]{10}$')

def is_valid_mobile(value):
    """Return True if mobile is exactly 10 digits."""
    if pd.isna(value):          # Handle NULL/NaN
        return False
    value = str(value).strip()  # Remove whitespace
    return bool(mobile_pattern.match(value))

# Create a boolean mask
mask = df['mobile_number'].apply(is_valid_mobile)

# ─── Step 3: Split into valid and invalid ──────────────
valid_df   = df[mask].copy()
invalid_df = df[~mask].copy()

# Add error details to invalid records
invalid_df['error_reason'] = invalid_df['mobile_number'].apply(
    lambda x: 'NULL value' if pd.isna(x)
    else f'Invalid length: {len(str(x).strip())} digits'
      if str(x).strip().isdigit()
    else 'Contains non-numeric characters'
)
invalid_df['error_timestamp'] = datetime.now()

# ─── Step 4: Load valid records to database ────────────
from sqlalchemy import create_engine
engine = create_engine('mssql+pyodbc://user:pass@server/db')
valid_df.to_sql('customers', engine, if_exists='append', index=False)

# ─── Step 5: Write errors back to S3 ───────────────────
error_csv = invalid_df.to_csv(index=False)
s3.put_object(
    Bucket='my-data-bucket',
    Key=f'errors/customers_errors_{datetime.now():%Y%m%d}.csv',
    Body=error_csv
)

# ─── Step 6: Log summary ───────────────────────────────
print(f"Total: {len(df)} | Valid: {len(valid_df)} | Invalid: {len(invalid_df)}")
# Output: Total: 100 | Valid: 98 | Invalid: 2

Why this regex? ^[0-9]{10}$

  • ^ — start of string (nothing before)
  • [0-9]{10} — exactly 10 digits
  • $ — end of string (nothing after)
  • This catches: alphabets, special characters, too short, too long, empty strings

Approach 2: SQL (Staging Table Pattern)

In many enterprise setups, the CSV is first bulk-loaded into a staging table (raw data, no constraints). Then SQL queries split valid vs. invalid records.

SQL SQL Server / T-SQL
-- Step 1: Create staging table (accepts everything)
CREATE TABLE stg_customers (
    customer_id     INT,
    customer_name   VARCHAR(100),
    mobile_number   VARCHAR(50),   -- VARCHAR, not INT (to catch "abc")
    email           VARCHAR(200),
    load_date       DATETIME DEFAULT GETDATE()
);

-- Step 2: Bulk load CSV into staging
BULK INSERT stg_customers
FROM '\\path\to\customers.csv'
WITH (FIELDTERMINATOR = ',', ROWTERMINATOR = '\n', FIRSTROW = 2);

-- Step 3: Move VALID records to production table
INSERT INTO customers (customer_id, customer_name, mobile_number, email)
SELECT customer_id, customer_name, mobile_number, email
FROM stg_customers
WHERE mobile_number IS NOT NULL
  AND LEN(mobile_number) = 10
  AND mobile_number NOT LIKE '%[^0-9]%';  -- no non-numeric chars

-- Step 4: Move INVALID records to error table
INSERT INTO error_log (customer_id, mobile_number, error_reason, error_date)
SELECT
    customer_id,
    mobile_number,
    CASE
        WHEN mobile_number IS NULL THEN 'NULL mobile number'
        WHEN mobile_number LIKE '%[^0-9]%' THEN 'Contains non-numeric characters'
        WHEN LEN(mobile_number) < 10 THEN 'Too short: ' + CAST(LEN(mobile_number) AS VARCHAR)
        WHEN LEN(mobile_number) > 10 THEN 'Too long: ' + CAST(LEN(mobile_number) AS VARCHAR)
    END,
    GETDATE()
FROM stg_customers
WHERE mobile_number IS NULL
   OR LEN(mobile_number) != 10
   OR mobile_number LIKE '%[^0-9]%';

-- Step 5: Clean up staging
TRUNCATE TABLE stg_customers;
Interview Tip

Always mention three things: (1) Never stop the whole batch for a few errors, (2) Log errors with enough detail to fix them, (3) Alert the team. The interviewer wants to see you think about the full pipeline, not just a WHERE clause.

Possible Follow-Up Questions

  • What if the file has 10 million rows? — Use chunked reading: pd.read_csv(file, chunksize=50000)
  • What if the mobile starts with country code (+91)? — Strip prefix first, then validate
  • How do you alert on errors? — SNS topic, email via SES, or Slack webhook
  • What if the same file is uploaded twice? — Track file names in a metadata table, check before processing

2

API Request Types

API Concept
What are the request types in APIs?

HTTP Methods — The 5 You Must Know

HTTP methods (also called "verbs") tell the server what action to take on a resource. Think of a resource as a "thing" (a user, an order, a record).

Method Purpose Has Body? Idempotent? Example
GET Read / Fetch data No Yes Get all users, get one user
POST Create new resource Yes No Create a new order
PUT Replace entire resource Yes Yes Update all fields of a user
PATCH Update part of resource Yes Yes Update only email of a user
DELETE Remove resource Optional Yes Delete a user account

What does "Idempotent" mean?

If you call the same request 10 times, the result is the same as calling it once. GET /users/5 always returns the same user. DELETE /users/5 — first call deletes, next 9 return "not found" (same end state). But POST /orders creates a new order every time — that is NOT idempotent.

FastAPI Code Examples

Python FastAPI decorators
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()

class User(BaseModel):
    name: str
    email: str
    mobile: str

# ─── GET: Read data ─────────────────────────────────────
@app.get("/users")
def get_all_users():
    return db.fetch_all_users()

@app.get("/users/{user_id}")
def get_user(user_id: int):
    user = db.find_user(user_id)
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    return user

# ─── POST: Create new resource ──────────────────────────
@app.post("/users", status_code=201)
def create_user(user: User):
    new_id = db.insert_user(user)
    return {"id": new_id, "message": "Created"}

# ─── PUT: Replace entire resource ───────────────────────
@app.put("/users/{user_id}")
def replace_user(user_id: int, user: User):
    db.update_user(user_id, user)  # overwrites ALL fields
    return {"message": "Replaced"}

# ─── PATCH: Update part of resource ─────────────────────
@app.patch("/users/{user_id}")
def update_email(user_id: int, email: str):
    db.update_field(user_id, "email", email)  # only email
    return {"message": "Updated"}

# ─── DELETE: Remove resource ────────────────────────────
@app.delete("/users/{user_id}")
def delete_user(user_id: int):
    db.remove_user(user_id)
    return {"message": "Deleted"}

PUT vs PATCH — The Classic Confusion

PUT replaces the ENTIRE resource. If you send a PUT with just {"email": "new@mail.com"}, the name and mobile fields become NULL/empty. PATCH updates ONLY the fields you send — everything else stays unchanged.

Simple Analogy

PUT = Tear down the house and rebuild it. PATCH = Fix just the broken window.

Possible Follow-Up Questions

  • What is the difference between PUT and POST? — POST creates (server assigns ID), PUT replaces at a known ID
  • What are status codes? — 200 OK, 201 Created, 400 Bad Request, 401 Unauthorized, 404 Not Found, 500 Server Error
  • What about HEAD and OPTIONS? — HEAD = GET without body (check if resource exists), OPTIONS = what methods are allowed (CORS preflight)

3

Self Join — Employee vs Manager Salary

SQL
Employee table has employee_code, salary, l1_employee_code. Extract employees whose salary is greater than their L1 manager's salary.

What is a Self Join?

A self join is when you join a table to itself. You treat the same table as two different tables by giving them different aliases. This is useful when a table has a relationship within itself — like employees and their managers being in the same table.

The Table Structure

employee_codeemployee_namesalaryl1_employee_code
101Alice90,000103
102Bob85,000103
103Charlie (Mgr)80,000105
104Diana70,000105
105Eve (VP)120,000NULL

Here, l1_employee_code points to the manager's employee_code. Alice (101) reports to Charlie (103). Charlie earns 80K, but Alice earns 90K — Alice earns more than her manager.

The Query

SQL Self Join
-- Find employees earning more than their L1 manager
SELECT
    e.employee_code,
    e.employee_name    AS employee_name,
    e.salary           AS employee_salary,
    m.employee_name    AS manager_name,
    m.salary           AS manager_salary,
    e.salary - m.salary AS salary_difference
FROM employees e                    -- "e" = employee
INNER JOIN employees m              -- "m" = manager (same table!)
    ON e.l1_employee_code = m.employee_code
WHERE e.salary > m.salary
ORDER BY salary_difference DESC;

Step-by-Step Explanation

  1. FROM employees e — First copy of the table: we treat this as the "employee" side
  2. INNER JOIN employees m — Second copy: we treat this as the "manager" side
  3. ON e.l1_employee_code = m.employee_code — Match each employee to their manager
  4. WHERE e.salary > m.salary — Filter: only employees earning more than their manager

Result

EmployeeEmp SalaryManagerMgr SalaryDifference
Alice90,000Charlie80,000+10,000
Bob85,000Charlie80,000+5,000

NULL Handling — Very Important

Eve (105) has l1_employee_code = NULL because she is the top of the hierarchy. With INNER JOIN, she is automatically excluded (no match). If you wanted to include employees without managers:

SQL LEFT JOIN version
-- Include employees whose manager is not in the table
SELECT
    e.employee_name,
    e.salary,
    COALESCE(m.employee_name, 'No Manager') AS manager_name,
    m.salary AS manager_salary
FROM employees e
LEFT JOIN employees m
    ON e.l1_employee_code = m.employee_code
WHERE e.salary > m.salary
   OR m.employee_code IS NULL;  -- also show employees with no manager
Interview Tip

Always mention NULL handling when discussing self joins. Ask: "Should I include employees who don't have a manager?" This shows you think about edge cases.

Possible Follow-Up Questions

  • How would you find employees earning more than their L2 (skip-level) manager? — Double self-join: employee → L1 → L2
  • What if you want the department average instead? — Use GROUP BY department + window function
  • Performance on large tables? — Ensure indexes on employee_code and l1_employee_code

4

Encryption

Security
Do you use encryption?

Encryption in ETL / Backend Systems

There are two main scenarios where encryption matters: data in transit (moving between systems) and data at rest (stored on disk).

TypeWhat it ProtectsHowExample
In Transit Data moving over network HTTPS / TLS / SSL API calls, file transfers, database connections
At Rest Data stored on disk AES-256 encryption S3 server-side encryption, database TDE
Hashing Passwords, integrity checks bcrypt, SHA-256 Password storage, file checksums
Tokens Authentication JWT (signed, not encrypted) API auth tokens, session management

Encryption vs Hashing — Key Difference

Encryption is reversible — you can get the original data back with the right key. Hashing is one-way — you can never get the original data back from the hash.

Python Encryption vs Hashing
# ─── Hashing (one-way, for passwords) ──────────────────
from passlib.hash import bcrypt

password = "user_password_123"
hashed = bcrypt.hash(password)
# "$2b$12$LJ3m4ys..." — cannot reverse this!

# Verify: hash the input again and compare
is_valid = bcrypt.verify("user_password_123", hashed)  # True

# ─── Encryption (two-way, for sensitive data) ──────────
from cryptography.fernet import Fernet

key = Fernet.generate_key()           # keep this secret!
cipher = Fernet(key)

# Encrypt
ssn = "123-45-6789"
encrypted = cipher.encrypt(ssn.encode())
# b'gAAAAABh...' — unreadable

# Decrypt (only with the same key)
original = cipher.decrypt(encrypted).decode()
# "123-45-6789" — got it back!

When to Use What

  • Passwords → Always HASH (bcrypt). Never encrypt passwords — if someone steals the key, all passwords are exposed
  • PII data (SSN, Aadhaar, credit card) → ENCRYPT. You need to read it later
  • API communication → HTTPS/TLS (automatic encryption in transit)
  • S3 files → Enable SSE-S3 or SSE-KMS (server-side encryption at rest)
  • JWT tokens → Signed (integrity), not encrypted. Anyone can READ the payload, but cannot MODIFY it

ETL-Specific Encryption Practices

  • S3: Enable default encryption on bucket (AES-256 or aws:kms)
  • Database connections: Always use SSL/TLS (sslmode=require in connection string)
  • SFTP transfers: SSH-based encryption (built-in), much safer than plain FTP
  • API keys / secrets: Store in AWS Secrets Manager or environment variables, never in code
  • Column-level encryption: Encrypt sensitive columns (mobile, email) in the database, decrypt only when needed
Interview Tip

Frame your answer as: "We use encryption at three levels — in transit (HTTPS/TLS), at rest (S3 encryption, database TDE), and for sensitive fields (column-level encryption for PII). For passwords, we never encrypt — we hash with bcrypt." This structured answer shows breadth of knowledge.


5

Providing Data to Outside Systems (Outbound ETL)

ETL API
On the ETL side, are you providing data to outside systems?

Three Patterns for Outbound Data

When your system needs to send data OUT to another company, team, or application, there are three main approaches.

PatternHow it WorksWhen to UseExamples
File-Based Generate CSV/Excel, drop on SFTP or S3 Batch processing, legacy systems Daily report to vendor, bank statement file
API-Based REST API that external systems call Real-time access, modern integrations Partner reads your API for latest data
Event-Based Push events to a queue/stream Real-time triggers, microservices Kafka, SQS, webhooks

Pattern 1: File-Based (Most Common in ETL)

Python Generate file and push to SFTP/S3
import pandas as pd
from sqlalchemy import create_engine
import boto3
from datetime import datetime

# Step 1: Extract data from database
engine = create_engine("mssql+pyodbc://...")
query = """
    SELECT customer_id, name, email, status
    FROM customers
    WHERE modified_date >= DATEADD(day, -1, GETDATE())
"""
df = pd.read_sql(query, engine)

# Step 2: Transform (select only outbound columns, mask PII)
outbound_df = df[['customer_id', 'name', 'status']]

# Step 3: Write to CSV and upload to S3 (for partner pickup)
filename = f"outbound/customers_{datetime.now():%Y%m%d}.csv"
csv_buffer = outbound_df.to_csv(index=False)

s3 = boto3.client('s3')
s3.put_object(
    Bucket='partner-data-exchange',
    Key=filename,
    Body=csv_buffer
)

# Step 4: Log the transfer
log_transfer(
    records=len(outbound_df),
    destination="partner-s3",
    filename=filename
)

Pattern 2: API-Based (You Expose an API)

Python FastAPI endpoint for partners
@app.get("/api/v1/customers")
def get_customers(
    modified_since: str = None,   # "2026-03-22"
    limit: int = 100,
    offset: int = 0,
    api_key: str = Header(...)     # auth required
):
    validate_api_key(api_key)       # check partner has access

    query = db.query(Customer)
    if modified_since:
        query = query.filter(Customer.modified_date >= modified_since)

    results = query.offset(offset).limit(limit).all()

    return {
        "data": results,
        "count": len(results),
        "next_offset": offset + limit
    }

The partner system calls GET /api/v1/customers?modified_since=2026-03-22 to pull only changed records. This is called a pull model — the consumer asks for data when they need it.

Pattern 3: Event-Based (Push Model)

When a record changes, you push an event to a message queue. The consumer listens and reacts in real-time. Common tools: Kafka, AWS SQS, webhooks.

Your System Partner System ┌──────────┐ webhook POST ┌──────────────┐ │ Database │ ──── onChange ────► │ Their API │ │ (trigger) │ │ (listener) │ └──────────┘ └──────────────┘ OR with a queue: Your System ──► SQS/Kafka ──► Partner's Consumer
How to Answer

Say: "Yes, we provide data outbound in two ways: (1) File drops to S3/SFTP for batch consumers, and (2) REST APIs for real-time consumers. For file-based, we generate daily CSV extracts. For API-based, we expose paginated endpoints with date filters so partners can pull delta changes."


6

Column-Level Change Detection (CDC)

SQL Python CDC
Table has 20 columns, only providing 5 columns outbound. If anything in those 5 columns changes, data should trigger. If 6th or 7th column changes, should NOT trigger.

What is Change Data Capture (CDC)?

CDC means detecting which rows have changed since the last time you checked. The twist here is: you only care about changes in specific columns (the 5 outbound ones), not all 20.

Table: 20 columns ┌────────────────────────────────────┐ │ col1 col2 col3 col4 col5 ... │ ← 5 outbound columns │ col6 col7 col8 ... col20 │ ← 15 internal columns └────────────────────────────────────┘ Rule: Only trigger when col1-col5 change. Ignore changes to col6-col20.

Approach 1: Hash-Based Comparison (Most Efficient)

Create a hash of only the 5 outbound columns. Store the hash. Next run, recompute the hash. If the hash changed, those columns changed. If the hash is the same, skip it.

SQL SQL Server — HASHBYTES approach
-- Step 1: Add a hash column to track outbound changes
ALTER TABLE customers ADD outbound_hash VARBINARY(32);

-- Step 2: Compute hash of ONLY the 5 outbound columns
UPDATE customers
SET outbound_hash = HASHBYTES(
    'SHA2_256',
    CONCAT(
        COALESCE(CAST(name AS VARCHAR(MAX)), '__NULL__'), '|',
        COALESCE(CAST(email AS VARCHAR(MAX)), '__NULL__'), '|',
        COALESCE(CAST(status AS VARCHAR(MAX)), '__NULL__'), '|',
        COALESCE(CAST(mobile AS VARCHAR(MAX)), '__NULL__'), '|',
        COALESCE(CAST(address AS VARCHAR(MAX)), '__NULL__')
    )
);

-- Step 3: On next ETL run, find rows where hash changed
SELECT c.*
FROM customers c
INNER JOIN outbound_snapshot prev
    ON c.customer_id = prev.customer_id
WHERE c.outbound_hash != prev.outbound_hash;

-- These are the ONLY rows to send outbound
Why COALESCE?

NULL in a CONCAT makes the whole thing NULL. So CONCAT('Alice', NULL, 'active') = NULL. By replacing NULLs with a placeholder string '__NULL__', you ensure the hash is always computed correctly. Without this, two completely different rows could produce the same hash (both NULL).

Approach 2: Direct Column Comparison (SQL)

Compare current values vs. previous snapshot, column by column.

SQL Column-by-column comparison
-- Compare current table vs last snapshot
SELECT curr.*
FROM customers curr
INNER JOIN customers_snapshot prev
    ON curr.customer_id = prev.customer_id
WHERE
    -- Check ONLY the 5 outbound columns
    ISNULL(curr.name, '')    != ISNULL(prev.name, '')
    OR ISNULL(curr.email, '')   != ISNULL(prev.email, '')
    OR ISNULL(curr.status, '')  != ISNULL(prev.status, '')
    OR ISNULL(curr.mobile, '')  != ISNULL(prev.mobile, '')
    OR ISNULL(curr.address, '') != ISNULL(prev.address, '');

-- Note: Changes to col6-col20 are completely ignored

Approach 3: Python with Pandas Merge

Python CDC with pandas
outbound_cols = ['name', 'email', 'status', 'mobile', 'address']
key_col = 'customer_id'

# Current data (from DB) and previous snapshot
current = pd.read_sql("SELECT * FROM customers", engine)
previous = pd.read_csv("last_snapshot.csv")

# Merge on key, compare only outbound columns
merged = current.merge(
    previous[[key_col] + outbound_cols],
    on=key_col,
    suffixes=('_curr', '_prev'),
    how='left'
)

# Find rows where ANY of the 5 outbound columns changed
changed_mask = False
for col in outbound_cols:
    changed_mask = changed_mask | (
        merged[f'{col}_curr'].fillna('__NULL__') !=
        merged[f'{col}_prev'].fillna('__NULL__')
    )

changed_rows = current[merged[changed_mask].index]
print(f"Rows to send outbound: {len(changed_rows)}")

Possible Follow-Up Questions

  • What if a new row is inserted? — Use LEFT JOIN; if prev is NULL, it is a new row → always send
  • What if a row is deleted? — Track in snapshot; if current is missing but prev exists → send delete event
  • Hash collision risk? — SHA-256 has virtually zero collision probability in practice
  • Performance? — Hash approach is faster (one column compare vs. five). Index the hash column

7

Temporary Tables — # vs ## (SQL Server)

SQL Server
What is the difference between single hash table and double hash table?

Local (#) vs Global (##) Temp Tables

In SQL Server, temporary tables are created in the tempdb database. The number of hash symbols determines who can see the table.

Feature#local_temp##global_temp
VisibilityOnly the session that created itAll sessions / all users
LifetimeDropped when session endsDropped when creating session ends AND no other session is using it
Name conflictTwo sessions can each create #temp (they are separate)Only one ##temp can exist at a time
Use caseIntermediate ETL steps, query stagingSharing data between stored procedures, cross-session work
Stored intempdbtempdb
SQL Creating temp tables
-- ─── Local Temp Table (single #) ──────────────────────
-- Only THIS session/connection can see it
CREATE TABLE #my_staging (
    id      INT,
    name    VARCHAR(100),
    amount  DECIMAL(10,2)
);

INSERT INTO #my_staging VALUES (1, 'Alice', 5000.00);

-- Use it in your ETL logic
SELECT * FROM #my_staging;

-- Automatically dropped when you close the connection
-- (or manually: DROP TABLE #my_staging)

-- ─── Global Temp Table (double ##) ────────────────────
-- ALL sessions can see and use it
CREATE TABLE ##shared_lookup (
    code    VARCHAR(10),
    label   VARCHAR(100)
);

INSERT INTO ##shared_lookup VALUES ('US', 'United States');

-- Another user in a different session can do:
-- SELECT * FROM ##shared_lookup;  ← This works!

-- Dropped when the CREATING session ends AND
-- no other session is referencing it

Real-World ETL Usage

  • Local (#): You are running an ETL job. You need a temporary holding area to clean data before inserting into the final table. Use #staging. It is private to your session, so no one else interferes.
  • Global (##): You are building a shared lookup table that multiple stored procedures need to reference across different sessions. Use ##lookup. But use this sparingly — it is like a global variable and can cause race conditions.
Warning

Avoid ##global_temp in production ETL. If two ETL jobs run at the same time and both try to create ##staging, the second one fails. Use local temp tables (#) or proper staging tables instead.

Also Know: Table Variables and CTEs

Interviewers sometimes compare these. Quick overview:

TypeSyntaxScopeStats/Indexes
#tempCREATE TABLE #tSessionYes (indexes, stats)
##tempCREATE TABLE ##tAll sessionsYes
@variableDECLARE @t TABLEBatch/procedureLimited (PK only, no stats)
CTEWITH cte ASSingle queryNo (virtual)

8

Delete Old Files from S3 (Python + boto3)

Python AWS S3
Write a program to delete files from S3 folder older than 30 days.

Full Python Solution

Python s3_cleanup.py
import boto3
from datetime import datetime, timezone, timedelta

def delete_old_s3_files(
    bucket_name: str,
    prefix: str,
    days_old: int = 30,
    dry_run: bool = True
):
    """Delete S3 objects older than `days_old` days."""
    s3 = boto3.client('s3')
    cutoff_date = datetime.now(timezone.utc) - timedelta(days=days_old)

    deleted_count = 0
    total_size = 0

    # Paginator handles buckets with 1000+ objects
    paginator = s3.get_paginator('list_objects_v2')
    pages = paginator.paginate(Bucket=bucket_name, Prefix=prefix)

    for page in pages:
        if 'Contents' not in page:
            print("No objects found.")
            return

        for obj in page['Contents']:
            last_modified = obj['LastModified']  # already timezone-aware
            key = obj['Key']
            size = obj['Size']

            if last_modified < cutoff_date:
                age_days = (datetime.now(timezone.utc) - last_modified).days

                if dry_run:
                    print(f"[DRY RUN] Would delete: {key} "
                          f"(age: {age_days}d, size: {size:,}B)")
                else:
                    s3.delete_object(Bucket=bucket_name, Key=key)
                    print(f"[DELETED] {key} (age: {age_days}d)")

                deleted_count += 1
                total_size += size

    print(f"\nSummary: {deleted_count} files, {total_size / 1024 / 1024:.1f} MB")
    if dry_run:
        print("Run with dry_run=False to actually delete.")

# ─── Usage ──────────────────────────────────────────────
delete_old_s3_files(
    bucket_name='my-etl-bucket',
    prefix='raw-data/',        # only this "folder"
    days_old=30,
    dry_run=True               # set False to actually delete
)

Key Points to Mention in Interview

1. Pagination is Essential

list_objects_v2 returns max 1000 objects per call. If the folder has 50,000 files, you need a paginator. Without it, you only process the first 1000 and silently miss the rest.

2. Timezone Awareness

S3's LastModified is in UTC with timezone info. You MUST compare it with datetime.now(timezone.utc) (timezone-aware), not plain datetime.now(). Otherwise Python throws a TypeError: can't compare offset-naive and offset-aware datetimes.

3. Dry Run Pattern

Always implement a dry run first. Deleting S3 objects is permanent (unless versioning is enabled). Show the interviewer you think about safety.

4. Batch Delete for Performance

Python Batch delete (faster for many files)
# Collect keys to delete, then batch delete (up to 1000 at once)
keys_to_delete = [{'Key': key} for key in old_keys]

# Batch in groups of 1000
for i in range(0, len(keys_to_delete), 1000):
    batch = keys_to_delete[i:i+1000]
    s3.delete_objects(
        Bucket=bucket_name,
        Delete={'Objects': batch}
    )

Alternative: S3 Lifecycle Policy (No Code Needed)

For a production system, you might not even write code. AWS S3 has built-in lifecycle rules.

AWS CLI Set lifecycle rule
aws s3api put-bucket-lifecycle-configuration \
  --bucket my-etl-bucket \
  --lifecycle-configuration '{
    "Rules": [{
      "ID": "DeleteOldRawData",
      "Filter": {"Prefix": "raw-data/"},
      "Status": "Enabled",
      "Expiration": {"Days": 30}
    }]
  }'

This automatically deletes objects in raw-data/ after 30 days. No Lambda, no cron job, no code to maintain. AWS handles it for free.

Interview Tip

Mention BOTH approaches: "I would write the Python script for one-time cleanup or custom logic, but for ongoing maintenance, I would set up an S3 lifecycle policy — it is zero-cost, serverless, and requires no maintenance." This shows you know the cloud-native way.

Possible Follow-Up Questions

  • How to schedule this? — AWS Lambda + EventBridge (cron), or Airflow DAG
  • What if versioning is on? — Delete markers are added, actual bytes stay. Need to delete versions too
  • Cost of S3 delete operations? — DELETE requests are free. LIST requests cost $0.005/1000

9

Delete Duplicates Using CTE

SQL
How do you delete duplicate entries from a table using CTE?

The Classic CTE + ROW_NUMBER() Pattern

This is one of the most common SQL interview questions. The idea: assign a row number to each group of duplicates, then delete all rows where the row number is greater than 1 (keep only the first one).

Sample Data (with duplicates)

idnameemailmobile
1Alicealice@mail.com9876543210
2Alicealice@mail.com9876543210
3Bobbob@mail.com8765432109
4Bobbob@mail.com8765432109
5Bobbob@mail.com8765432109
6Charliecharlie@mail.com7654321098

Alice appears 2 times. Bob appears 3 times. Charlie appears 1 time. After dedup: Alice 1, Bob 1, Charlie 1 = 3 rows.

The Query

SQL Delete duplicates with CTE
-- Step 1: Use CTE to assign row numbers within each duplicate group
WITH cte_duplicates AS (
    SELECT
        *,
        ROW_NUMBER() OVER (
            PARTITION BY name, email, mobile   -- columns that define "duplicate"
            ORDER BY id                         -- keep the lowest id
        ) AS row_num
    FROM customers
)

-- Step 2: Delete all rows where row_num > 1 (the duplicates)
DELETE FROM cte_duplicates
WHERE row_num > 1;

How It Works Step by Step

After the CTE runs, the data looks like this internally:

idnameemailrow_numAction
1Alicealice@mail.com1KEEP
2Alicealice@mail.com2DELETE
3Bobbob@mail.com1KEEP
4Bobbob@mail.com2DELETE
5Bobbob@mail.com3DELETE
6Charliecharlie@mail.com1KEEP

PARTITION BY name, email, mobile creates groups of identical rows. ROW_NUMBER() numbers each row within its group: 1, 2, 3... The DELETE WHERE row_num > 1 removes all copies except the first.

Important Decisions to Discuss

1. Which columns define "duplicate"?

You must decide what makes two rows "the same". Is it name + email? Or name + email + mobile? This goes in PARTITION BY.

2. Which row to KEEP?

The ORDER BY clause decides which row gets row_num = 1 (kept). Common choices:

  • ORDER BY id — keep the oldest (first inserted)
  • ORDER BY id DESC — keep the newest (latest inserted)
  • ORDER BY created_date DESC — keep the most recently updated

3. Safety: Always Preview First

SQL Preview before deleting
-- First: SEE what will be deleted (don't actually delete)
WITH cte_duplicates AS (
    SELECT *, ROW_NUMBER() OVER (
        PARTITION BY name, email, mobile
        ORDER BY id
    ) AS row_num
    FROM customers
)
SELECT * FROM cte_duplicates WHERE row_num > 1;
-- ↑ This SHOWS duplicates. Replace SELECT with DELETE when satisfied.

Alternative: Table Without an ID Column

If the table has no unique id column, you can still use CTE. The trick is to use ORDER BY (SELECT NULL) or any column — you just need to pick one row per group.

SQL No ID column
WITH cte AS (
    SELECT
        *,
        ROW_NUMBER() OVER (
            PARTITION BY name, email, mobile
            ORDER BY (SELECT NULL)   -- arbitrary order, just pick one
        ) AS rn
    FROM customers
)
DELETE FROM cte WHERE rn > 1;

Possible Follow-Up Questions

  • Can you do this without CTE? — Yes, subquery with NOT IN: DELETE FROM t WHERE id NOT IN (SELECT MIN(id) ... GROUP BY ...)
  • How to prevent duplicates in the future? — Add a UNIQUE constraint or UNIQUE index
  • What about performance on millions of rows? — Delete in batches (TOP 10000 in a loop) to avoid log file explosion

10

Dashboard for ETL Monitoring

Architecture Python SQL
How will you create a dashboard to show management how much data is transferred daily/monthly?

Architecture Overview

You need three things: (1) Log every ETL run into a metrics table, (2) Expose the data via an API or direct DB connection, (3) Visualize with a dashboard tool.

ETL Jobs (Python / SSIS / Airflow) │ │ (after each run, log metrics) ▼ ┌──────────────────────────────┐ │ etl_run_log (Database) │ │ - job_name, run_date │ │ - records_read │ │ - records_written │ │ - records_failed │ │ - duration_seconds │ │ - status (success/failed) │ └──────────────────────────────┘ │ ├──── Direct connection ──── Power BI / Tableau │ └──── FastAPI endpoint ──── Custom React Dashboard

Step 1: Create a Metrics/Log Table

SQL ETL metrics table
CREATE TABLE etl_run_log (
    run_id              INT IDENTITY(1,1) PRIMARY KEY,
    job_name            VARCHAR(100),       -- "customer_import", "order_sync"
    source_system       VARCHAR(50),        -- "S3", "SFTP", "API"
    target_system       VARCHAR(50),        -- "SQL Server", "Data Warehouse"
    run_date            DATETIME DEFAULT GETDATE(),
    records_read        INT,
    records_written     INT,
    records_failed      INT,
    file_name           VARCHAR(500),
    file_size_mb        DECIMAL(10,2),
    duration_seconds    INT,
    status              VARCHAR(20),        -- 'SUCCESS', 'FAILED', 'PARTIAL'
    error_message       VARCHAR(MAX)
);

Step 2: Log Metrics After Every ETL Run

Python Log ETL metrics
import time
from datetime import datetime

def run_etl_with_logging(job_name: str, source: str, target: str):
    start_time = time.time()
    status = "SUCCESS"
    error_msg = None
    records_read = 0
    records_written = 0
    records_failed = 0

    try:
        # ── Your actual ETL logic here ────────────
        df = extract_from_s3(source)
        records_read = len(df)

        valid, invalid = validate(df)
        records_failed = len(invalid)

        load_to_db(valid, target)
        records_written = len(valid)
        # ─────────────────────────────────────────

    except Exception as e:
        status = "FAILED"
        error_msg = str(e)

    finally:
        duration = int(time.time() - start_time)

        # Log to database
        db.execute("""
            INSERT INTO etl_run_log
            (job_name, source_system, target_system,
             records_read, records_written, records_failed,
             duration_seconds, status, error_message)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
        """, (
            job_name, source, target,
            records_read, records_written, records_failed,
            duration, status, error_msg
        ))

Step 3: Dashboard Queries

SQL Dashboard queries for Power BI / Tableau
-- Daily transfer summary (last 30 days)
SELECT
    CAST(run_date AS DATE)   AS transfer_date,
    COUNT(*)                  AS total_jobs,
    SUM(records_read)         AS total_records_read,
    SUM(records_written)      AS total_records_written,
    SUM(records_failed)       AS total_records_failed,
    SUM(file_size_mb)         AS total_data_mb,
    AVG(duration_seconds)     AS avg_duration_sec,
    SUM(CASE WHEN status = 'FAILED' THEN 1 ELSE 0 END) AS failed_jobs
FROM etl_run_log
WHERE run_date >= DATEADD(day, -30, GETDATE())
GROUP BY CAST(run_date AS DATE)
ORDER BY transfer_date;

-- Monthly summary (for management)
SELECT
    FORMAT(run_date, 'yyyy-MM')  AS month,
    job_name,
    SUM(records_written)          AS total_records,
    SUM(file_size_mb)             AS total_gb,
    ROUND(
        100.0 * SUM(CASE WHEN status = 'SUCCESS' THEN 1 ELSE 0 END)
        / COUNT(*), 1
    )                             AS success_rate_pct
FROM etl_run_log
GROUP BY FORMAT(run_date, 'yyyy-MM'), job_name
ORDER BY month DESC, total_records DESC;

What Metrics to Track (Key Dashboard KPIs)

MetricWhy It MattersVisualization
Records transferred (daily)Volume trending up/downLine chart
Success rate %Reliability of pipelinesGauge / KPI card
Failed records countData quality issuesBar chart (by job)
Data volume (MB/GB)Storage & bandwidth costsArea chart
Average durationPerformance degradationLine chart with threshold
Job-level breakdownWhich pipeline has issuesTable / heatmap
Error messagesQuick debuggingTable with filters

Visualization Options

  • Power BI / Tableau: Connect directly to the SQL table. Best for management — scheduled refresh, email reports, drill-down. Most enterprises already have licenses.
  • Custom Web Dashboard: FastAPI backend + React frontend. More control, but more work to build. Good if you need real-time updates or custom alerting.
  • Grafana: Free, great for real-time monitoring. Connect to SQL Server via plugin. Best for operations teams (live dashboards on screens).
  • Apache Airflow UI: If you use Airflow for ETL scheduling, it has built-in run history, duration tracking, and failure alerts.
Interview Tip

The key insight: the dashboard is only as good as the logging. If your ETL jobs do not log metrics into a structured table, there is nothing to visualize. Start your answer with "First, I would create an ETL metrics table and ensure every job logs to it..."


Interview Tips & Strategy

General advice for ETL and data engineering interviews.

Before the Interview

  • Practice SQL out loud — Write queries on paper or whiteboard. In interviews you often do not have an IDE, so practice without autocomplete.
  • Know your resume — Every project and tool you listed will be asked about. If you wrote "ETL with Python", be ready to explain your exact pipeline.
  • Prepare your "story" for each project — What was the problem? What did you do? What was the result? Keep it under 2 minutes.

During the Interview

  • Think out loud — Say "I would first check if..." or "My approach would be...". The interviewer wants to see your thought process, not just the final answer.
  • Ask clarifying questions — "Should I handle NULLs?" "Is this SQL Server or MySQL?" "How many records are we talking about?" This shows real-world experience.
  • Mention edge cases before being asked — NULLs, empty strings, large datasets, concurrent access. This is what separates good from great answers.
  • Give two approaches when possible — "You can do this in SQL with a CTE, or in Python with pandas. Here is the SQL approach..." Then explain when you would use each.
  • Connect to real experience — "In my project, we had a similar scenario where..." Even brief, this makes your answer credible.

Common Mistakes to Avoid

  • Jumping to code immediately — First explain the approach in plain English, then write code.
  • Ignoring NULLs — In almost every SQL question, NULL handling matters. Always mention it.
  • Over-engineering — If the interviewer asks for a simple script, do not design a microservice architecture. Match the scope of the question.
  • Saying "I do not know" without trying — Instead: "I have not used that exact tool, but based on my understanding of similar concepts, I think it works like..."
  • Not asking about scale — "How many records?" changes the answer entirely. 100 rows vs. 100 million rows require different approaches.

Topics to Brush Up On

CategoryMust-Know Topics
SQLJOINs (all types), Window functions (ROW_NUMBER, RANK, LEAD/LAG), CTEs, Subqueries, GROUP BY + HAVING, MERGE/UPSERT
PythonPandas (read, filter, merge, groupby), boto3 (S3 operations), SQLAlchemy, file handling, error handling
ETL ConceptsStaging tables, CDC, SCD Type 1/2, data quality checks, error handling, idempotency
AWSS3 (read, write, lifecycle), Lambda (triggers), Glue (ETL), Redshift (data warehouse basics)
ArchitectureBatch vs. streaming, data lake vs. warehouse, star schema, normalization vs. denormalization
Golden Rule

The best interview answers follow this structure: (1) State the approach in one sentence, (2) Explain why this approach, (3) Write the code, (4) Mention edge cases, (5) Offer an alternative. This structure works for every technical question.