Questions at a Glance
Click any card to jump to the detailed answer.
Data Validation (S3 + Python + SQL)
Validate mobile numbers in CSV from S3, separate valid/invalid records
Q2API Request Types
GET, POST, PUT, PATCH, DELETE with real-world usage
Q3Self Join — Employee vs Manager Salary
Employees earning more than their L1 manager
Q4Encryption
Data in transit, at rest, JWT, hashing vs encryption
Q5Outbound ETL
Providing data to outside systems via files, APIs, events
Q6Column-Level CDC
Trigger only when specific columns change, ignore the rest
Q7Temp Tables (# vs ##)
Local vs global temporary tables in SQL Server
Q8Delete Old S3 Files (boto3)
Python program to delete files older than 30 days from S3
Q9Delete Duplicates Using CTE
ROW_NUMBER + PARTITION BY to remove duplicate rows
Q10ETL Monitoring Dashboard
Track and visualize data transfer metrics for management
Data Validation (S3 + Python + SQL)
Python SQL AWS S3The 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.
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.
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.
-- 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;
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
API Request Types
API ConceptHTTP 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
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.
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)
Self Join — Employee vs Manager Salary
SQLWhat 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_code | employee_name | salary | l1_employee_code |
|---|---|---|---|
| 101 | Alice | 90,000 | 103 |
| 102 | Bob | 85,000 | 103 |
| 103 | Charlie (Mgr) | 80,000 | 105 |
| 104 | Diana | 70,000 | 105 |
| 105 | Eve (VP) | 120,000 | NULL |
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
-- 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
FROM employees e— First copy of the table: we treat this as the "employee" sideINNER JOIN employees m— Second copy: we treat this as the "manager" sideON e.l1_employee_code = m.employee_code— Match each employee to their managerWHERE e.salary > m.salary— Filter: only employees earning more than their manager
Result
| Employee | Emp Salary | Manager | Mgr Salary | Difference |
|---|---|---|---|---|
| Alice | 90,000 | Charlie | 80,000 | +10,000 |
| Bob | 85,000 | Charlie | 80,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:
-- 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
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_codeandl1_employee_code
Encryption
SecurityEncryption 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).
| Type | What it Protects | How | Example |
|---|---|---|---|
| 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.
# ─── 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-256oraws:kms) - Database connections: Always use SSL/TLS (
sslmode=requirein 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
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.
Providing Data to Outside Systems (Outbound ETL)
ETL APIThree Patterns for Outbound Data
When your system needs to send data OUT to another company, team, or application, there are three main approaches.
| Pattern | How it Works | When to Use | Examples |
|---|---|---|---|
| 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)
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)
@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.
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."
Column-Level Change Detection (CDC)
SQL Python CDCWhat 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.
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.
-- 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
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.
-- 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
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
Temporary Tables — # vs ## (SQL Server)
SQL ServerLocal (#) 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 |
|---|---|---|
| Visibility | Only the session that created it | All sessions / all users |
| Lifetime | Dropped when session ends | Dropped when creating session ends AND no other session is using it |
| Name conflict | Two sessions can each create #temp (they are separate) | Only one ##temp can exist at a time |
| Use case | Intermediate ETL steps, query staging | Sharing data between stored procedures, cross-session work |
| Stored in | tempdb | tempdb |
-- ─── 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.
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:
| Type | Syntax | Scope | Stats/Indexes |
|---|---|---|---|
#temp | CREATE TABLE #t | Session | Yes (indexes, stats) |
##temp | CREATE TABLE ##t | All sessions | Yes |
@variable | DECLARE @t TABLE | Batch/procedure | Limited (PK only, no stats) |
CTE | WITH cte AS | Single query | No (virtual) |
Delete Old Files from S3 (Python + boto3)
Python AWS S3Full Python Solution
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
# 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 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.
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
Delete Duplicates Using CTE
SQLThe 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)
| id | name | mobile | |
|---|---|---|---|
| 1 | Alice | alice@mail.com | 9876543210 |
| 2 | Alice | alice@mail.com | 9876543210 |
| 3 | Bob | bob@mail.com | 8765432109 |
| 4 | Bob | bob@mail.com | 8765432109 |
| 5 | Bob | bob@mail.com | 8765432109 |
| 6 | Charlie | charlie@mail.com | 7654321098 |
Alice appears 2 times. Bob appears 3 times. Charlie appears 1 time. After dedup: Alice 1, Bob 1, Charlie 1 = 3 rows.
The Query
-- 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:
| id | name | row_num | Action | |
|---|---|---|---|---|
| 1 | Alice | alice@mail.com | 1 | KEEP |
| 2 | Alice | alice@mail.com | 2 | DELETE |
| 3 | Bob | bob@mail.com | 1 | KEEP |
| 4 | Bob | bob@mail.com | 2 | DELETE |
| 5 | Bob | bob@mail.com | 3 | DELETE |
| 6 | Charlie | charlie@mail.com | 1 | KEEP |
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
-- 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.
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
Dashboard for ETL Monitoring
Architecture Python SQLArchitecture 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.
Step 1: Create a Metrics/Log 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
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
-- 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)
| Metric | Why It Matters | Visualization |
|---|---|---|
| Records transferred (daily) | Volume trending up/down | Line chart |
| Success rate % | Reliability of pipelines | Gauge / KPI card |
| Failed records count | Data quality issues | Bar chart (by job) |
| Data volume (MB/GB) | Storage & bandwidth costs | Area chart |
| Average duration | Performance degradation | Line chart with threshold |
| Job-level breakdown | Which pipeline has issues | Table / heatmap |
| Error messages | Quick debugging | Table 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.
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
| Category | Must-Know Topics |
|---|---|
| SQL | JOINs (all types), Window functions (ROW_NUMBER, RANK, LEAD/LAG), CTEs, Subqueries, GROUP BY + HAVING, MERGE/UPSERT |
| Python | Pandas (read, filter, merge, groupby), boto3 (S3 operations), SQLAlchemy, file handling, error handling |
| ETL Concepts | Staging tables, CDC, SCD Type 1/2, data quality checks, error handling, idempotency |
| AWS | S3 (read, write, lifecycle), Lambda (triggers), Glue (ETL), Redshift (data warehouse basics) |
| Architecture | Batch vs. streaming, data lake vs. warehouse, star schema, normalization vs. denormalization |
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.