PeopleStrong Integration Engineer

Complete Interview Preparation Guide — 85+ Scenario-Based Questions

85+
Questions
5
Sections
50+
Code Examples
85+
Scenarios

1 API to API Integration

Q1. How do you transfer data from one API to another?
ScenarioCode

You build a pipeline with three phases: Extract (GET from source), Transform (map fields, clean data), and Load (POST to target). The key is to never assume both APIs have the same schema. You read from the source, convert field names and data types to match the target's contract, handle pagination on the source side, and batch-insert on the target side. Always log every step for auditability.

import requests

def transfer_data(source_url, target_url, headers_src, headers_tgt):
    # Extract
    response = requests.get(source_url, headers=headers_src, timeout=30)
    response.raise_for_status()
    records = response.json()["data"]

    # Transform
    transformed = []
    for r in records:
        transformed.append({
            "employee_id": r["emp_code"],
            "full_name": f"{r['first_name']} {r['last_name']}",
            "email": r["email"].lower().strip(),
        })

    # Load
    resp = requests.post(target_url, json={"records": transformed}, headers=headers_tgt, timeout=60)
    resp.raise_for_status()
    return resp.json()
Real-world: PeopleStrong HRMS syncs employee data nightly to a payroll system. Source returns employee records, you transform designation codes to payroll grade codes, and POST batches to payroll API.
Follow-up: What happens if the target API rejects some records? — You implement partial failure handling: log rejected records, continue with valid ones, and create a retry queue for failures.
Q2. Source API has pagination (offset, cursor, keyset) — how do you handle each?
ScenarioCode

Offset-based: Use page/offset+limit params. Simple but slow for large datasets (DB scans all previous rows). Cursor-based: Server returns an opaque cursor token; you pass it in the next request. Best for real-time feeds. Keyset-based: Use the last record's ID/timestamp to fetch the next page (WHERE id > last_id). Most performant for sorted data. Always loop until the API signals no more data (empty results, no next cursor, or count < limit).

# Offset-based pagination
def fetch_all_offset(url, headers):
    all_records, page = [], 1
    while True:
        resp = requests.get(url, params={"page": page, "limit": 100}, headers=headers)
        data = resp.json()["data"]
        if not data:
            break
        all_records.extend(data)
        page += 1
    return all_records

# Cursor-based pagination
def fetch_all_cursor(url, headers):
    all_records, cursor = [], None
    while True:
        params = {"limit": 100}
        if cursor:
            params["cursor"] = cursor
        resp = requests.get(url, params=params, headers=headers)
        body = resp.json()
        all_records.extend(body["data"])
        cursor = body.get("next_cursor")
        if not cursor:
            break
    return all_records

# Keyset-based pagination
def fetch_all_keyset(url, headers):
    all_records, last_id = [], 0
    while True:
        resp = requests.get(url, params={"after_id": last_id, "limit": 100}, headers=headers)
        data = resp.json()["data"]
        if not data:
            break
        all_records.extend(data)
        last_id = data[-1]["id"]
    return all_records
Real-world: PeopleStrong's employee list API returns 10k+ records. Using offset pagination with page=100 would be slow. Switch to keyset pagination using last employee_id for sub-second page fetches.
Follow-up: What if records are added during pagination? — With offset, you may see duplicates or miss records. Keyset/cursor handles this better since it's based on a stable pointer.
Q3. Source API has rate limiting (429 status) — how do you handle?
ScenarioCode

When you get HTTP 429 (Too Many Requests), check the Retry-After header for how long to wait. Implement exponential backoff: wait 1s, 2s, 4s, 8s, etc. with jitter (random extra delay to avoid thundering herd). Also proactively respect rate limits by throttling your requests — if the API allows 100 req/min, space your calls to ~1 per 600ms. Use a token bucket or leaky bucket algorithm for smooth rate control.

import time, random

def api_call_with_retry(url, headers, max_retries=5):
    for attempt in range(max_retries):
        resp = requests.get(url, headers=headers, timeout=30)
        if resp.status_code == 200:
            return resp.json()
        if resp.status_code == 429:
            retry_after = int(resp.headers.get("Retry-After", 2 ** attempt))
            jitter = random.uniform(0, 1)
            wait = retry_after + jitter
            print(f"Rate limited. Waiting {wait:.1f}s (attempt {attempt+1})")
            time.sleep(wait)
        else:
            resp.raise_for_status()
    raise Exception("Max retries exceeded")
Real-world: You're syncing 5000 employees to a third-party benefits API that allows 60 requests/minute. Without throttling, you hit 429 after 60 calls. Solution: add a 1-second sleep between calls, or batch 50 records per call.
Follow-up: What is the thundering herd problem? — When many clients retry at exactly the same time after a 429, overwhelming the server again. Jitter randomizes retry times to spread the load.
Q4. Source API returns 500 error mid-transfer — what do you do?
ScenarioCode

A 500 error means server-side failure. First, don't panic and don't retry infinitely. Implement: (1) Retry with exponential backoff (maybe transient). (2) Log the exact page/offset where failure happened so you can resume. (3) If retry fails 3 times, save progress (last successful page) to a checkpoint file or DB. (4) Alert the team. (5) Resume from checkpoint when source recovers. Never restart from page 1 — that wastes time and may cause duplicates.

import json
from pathlib import Path

def fetch_with_checkpoint(url, headers, checkpoint_file="checkpoint.json"):
    # Resume from checkpoint if exists
    checkpoint = Path(checkpoint_file)
    last_page = 1
    all_records = []
    if checkpoint.exists():
        state = json.loads(checkpoint.read_text())
        last_page = state["last_successful_page"] + 1
        all_records = state.get("records", [])

    page = last_page
    while True:
        try:
            resp = requests.get(url, params={"page": page, "limit": 100}, headers=headers)
            resp.raise_for_status()
            data = resp.json()["data"]
            if not data:
                break
            all_records.extend(data)
            # Save checkpoint after each successful page
            checkpoint.write_text(json.dumps({"last_successful_page": page}))
            page += 1
        except requests.exceptions.HTTPError as e:
            if e.response.status_code == 500:
                print(f"Server error at page {page}. Saved checkpoint.")
                raise
    return all_records
Real-world: Nightly sync from PeopleStrong to attendance system. At page 45 of 100, source returns 500. Your checkpoint saves page 44. When you re-run at 6 AM, it resumes from page 45 instead of page 1, saving 20 minutes.
Follow-up: How do you differentiate between transient and permanent 500 errors? — Retry 3 times with backoff. If all fail, it's likely permanent (bad data, server bug). Log the request payload for debugging.
Q5. How do you handle authentication (API key, OAuth2, JWT)?
ScenarioCode

API Key: Simplest — pass key in header or query param. OAuth2 Client Credentials: Exchange client_id + client_secret for an access token, use token in Authorization header, refresh when expired. JWT: Generate a signed token with claims (issuer, expiry, scope), send in Bearer header. Always store credentials in environment variables or a vault, never in code. Handle token expiry gracefully — refresh before it expires, not after you get a 401.

import os

# API Key auth
headers_apikey = {"X-API-Key": os.environ["SOURCE_API_KEY"]}

# OAuth2 Client Credentials
def get_oauth_token(token_url, client_id, client_secret):
    resp = requests.post(token_url, data={
        "grant_type": "client_credentials",
        "client_id": client_id,
        "client_secret": client_secret,
    })
    resp.raise_for_status()
    token_data = resp.json()
    return token_data["access_token"], token_data["expires_in"]

# Usage with token refresh
class TokenManager:
    def __init__(self, token_url, client_id, client_secret):
        self.token_url = token_url
        self.client_id = client_id
        self.client_secret = client_secret
        self.token = None
        self.expires_at = 0

    def get_headers(self):
        if time.time() >= self.expires_at - 60:  # Refresh 60s before expiry
            self.token, expires_in = get_oauth_token(
                self.token_url, self.client_id, self.client_secret)
            self.expires_at = time.time() + expires_in
        return {"Authorization": f"Bearer {self.token}"}
Real-world: PeopleStrong uses OAuth2. You get a token valid for 1 hour. Your sync runs for 2 hours. Without auto-refresh, calls fail at hour 1. The TokenManager refreshes 60 seconds before expiry.
Follow-up: Where do you store client_secret securely? — Environment variables for local, AWS Secrets Manager / HashiCorp Vault for production. Never in code or config files committed to git.
Q6. Source returns 10,000 records but target accepts max 100 per batch — how?
ScenarioCode

Chunk the data into batches of the target's max size. Use Python's list slicing or a generator to split 10,000 records into 100 batches of 100. Send each batch sequentially (or in parallel with concurrency limits). Track which batches succeeded and which failed. If a batch fails, retry just that batch, not the entire dataset. This is called batch processing and is fundamental in integration work.

def chunk_list(data, size):
    for i in range(0, len(data), size):
        yield data[i:i + size]

def batch_upload(records, target_url, headers, batch_size=100):
    results = {"success": 0, "failed": 0, "failed_batches": []}
    for i, batch in enumerate(chunk_list(records, batch_size)):
        try:
            resp = requests.post(target_url, json={"records": batch}, headers=headers)
            resp.raise_for_status()
            results["success"] += len(batch)
            print(f"Batch {i+1}: {len(batch)} records uploaded")
        except Exception as e:
            results["failed"] += len(batch)
            results["failed_batches"].append({"batch": i+1, "error": str(e)})
    return results
Real-world: PeopleStrong exports 10,000 employee records. The payroll API accepts max 100 per POST. You split into 100 batches. Batch 47 fails due to a bad record — you log it and continue with batch 48.
Follow-up: Should you send batches in parallel? — Yes, with limits (e.g., 5 concurrent). Use asyncio or ThreadPoolExecutor. But respect target's rate limits.
Q7. How do you handle duplicate records during API sync?
ScenarioCode

Duplicates are the #1 data quality issue in integrations. Solutions: (1) Use a natural key (employee_id, email) and check before insert. (2) Use upsert (INSERT ON CONFLICT UPDATE) at the target. (3) Maintain a dedup set of already-processed IDs. (4) Make target API idempotent — same request twice produces same result. (5) Use a hash of the record to detect if content actually changed. Always prefer idempotent design over pre-checking, as it handles race conditions.

# Dedup before sending to target
def dedup_records(records, key_field="employee_id"):
    seen = set()
    unique = []
    for r in records:
        key = r[key_field]
        if key not in seen:
            seen.add(key)
            unique.append(r)
        else:
            print(f"Duplicate skipped: {key}")
    return unique

# SQL upsert at target (PostgreSQL)
upsert_sql = """
INSERT INTO employees (emp_id, name, email, department)
VALUES (%s, %s, %s, %s)
ON CONFLICT (emp_id) DO UPDATE SET
    name = EXCLUDED.name,
    email = EXCLUDED.email,
    department = EXCLUDED.department,
    updated_at = NOW()
"""
Real-world: A retry re-sends 500 records that were already processed. Without dedup, you'd have 500 duplicate employee entries. With upsert, existing records just get updated.
Follow-up: What if two records have the same email but different employee_ids? — That's a data quality issue in the source. Flag it, don't auto-merge. Send to a quarantine/review queue.
Q8. What if source API schema changes unexpectedly?
ScenarioTip

Schema drift is a silent killer. Defenses: (1) Schema validation at extraction — validate every response against an expected schema (use Pydantic, JSON Schema). (2) Fail fast — if a required field is missing, stop and alert rather than inserting nulls. (3) Version detection — check API version header on each call. (4) Flexible mapping — use a config-driven field mapping so changes don't require code changes. (5) Monitoring — alert on null rates, new fields, type mismatches.

from pydantic import BaseModel, ValidationError
from typing import Optional

class EmployeeSchema(BaseModel):
    emp_code: str
    first_name: str
    last_name: str
    email: str
    department: Optional[str] = None

def validate_records(records):
    valid, invalid = [], []
    for r in records:
        try:
            validated = EmployeeSchema(**r)
            valid.append(validated.model_dump())
        except ValidationError as e:
            invalid.append({"record": r, "errors": e.errors()})
    return valid, invalid
Real-world: Source renames "emp_code" to "employee_code" in v2. Without validation, your mapping breaks silently, inserting nulls. With Pydantic, you get an immediate validation error and alert.
Follow-up: How do you handle additive changes (new optional fields)? — Pydantic's model_config = ConfigDict(extra='allow') lets new fields pass through without breaking.
Q9. How do you implement retry logic with exponential backoff?
CodeTip

Exponential backoff means each retry waits longer: 1s, 2s, 4s, 8s, 16s. Add jitter (random 0-1s) to prevent synchronized retries. Set a max retries cap (usually 3-5). Only retry on transient errors (429, 500, 502, 503, 504, connection timeouts). Never retry on 400, 401, 403, 404 — those are permanent failures. Use the tenacity library in production for robust retry logic.

from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type

@retry(
    stop=stop_after_attempt(5),
    wait=wait_exponential(multiplier=1, min=1, max=60),
    retry=retry_if_exception_type(requests.exceptions.RequestException),
    before_sleep=lambda retry_state: print(
        f"Retry {retry_state.attempt_number}, waiting {retry_state.next_action.sleep:.1f}s"
    ),
)
def resilient_api_call(url, headers):
    resp = requests.get(url, headers=headers, timeout=30)
    if resp.status_code in (429, 500, 502, 503, 504):
        resp.raise_for_status()  # Will trigger retry
    return resp.json()
Interview tip: Always mention jitter and max retries. Saying "just retry" without backoff shows junior thinking. Interviewers love hearing "tenacity library" and "transient vs permanent errors".
Follow-up: What's the difference between exponential backoff and linear backoff? — Linear: 1s, 2s, 3s, 4s. Exponential: 1s, 2s, 4s, 8s. Exponential is better because it reduces load on a struggling server faster.
Q10. How do you log and monitor API-to-API transfers?
ScenarioCode

Structured logging is essential. Log: (1) Start/end timestamps. (2) Records fetched, transformed, loaded. (3) Errors with full context (URL, status code, response body). (4) Duration per phase. Use structured logging (JSON) so logs are searchable. For monitoring: set up alerts on failure count, duration spikes, and record count anomalies. Use correlation IDs to trace a single sync run across all logs.

import logging, uuid
from datetime import datetime

logging.basicConfig(level=logging.INFO, format='%(message)s')
logger = logging.getLogger(__name__)

def sync_with_logging(source_url, target_url):
    run_id = str(uuid.uuid4())[:8]
    start = datetime.now()
    logger.info(f"[{run_id}] Sync started at {start}")

    try:
        # Extract
        records = fetch_all_offset(source_url, headers={})
        logger.info(f"[{run_id}] Extracted {len(records)} records")

        # Transform
        transformed = [transform(r) for r in records]
        logger.info(f"[{run_id}] Transformed {len(transformed)} records")

        # Load
        result = batch_upload(transformed, target_url, headers={})
        duration = (datetime.now() - start).total_seconds()
        logger.info(f"[{run_id}] Sync complete in {duration:.1f}s | "
                    f"Success: {result['success']}, Failed: {result['failed']}")
    except Exception as e:
        logger.error(f"[{run_id}] Sync FAILED: {e}")
        raise
Real-world: Monday morning, payroll team says "data is missing." You search logs by run_id, find that Sunday's sync extracted 5000 records but only loaded 4800. The log shows batch 37 failed with a 500 error.
Follow-up: What monitoring tools do you use? — ELK stack (Elasticsearch, Logstash, Kibana), Datadog, CloudWatch, or Grafana. Set alerts on: sync failures, duration > 2x normal, record count < 80% of average.
Q11. What is idempotency and why is it important?
ScenarioCode

An operation is idempotent if executing it multiple times produces the same result as executing it once. In integration, this means if your sync runs twice accidentally, the data should be the same — no duplicates, no incorrect updates. Implement via: (1) Use unique keys + upsert instead of insert. (2) Send an idempotency key (unique ID per request) so the target can recognize duplicates. (3) Design target endpoints to be naturally idempotent (PUT over POST where possible). This is critical because network failures cause retries, and retries without idempotency cause data corruption.

# Idempotent request with idempotency key
import hashlib

def idempotent_post(url, record, headers):
    # Generate deterministic key from record content
    key = hashlib.sha256(json.dumps(record, sort_keys=True).encode()).hexdigest()
    headers["Idempotency-Key"] = key
    resp = requests.post(url, json=record, headers=headers)
    return resp.json()

# Target API checks: if key exists, return cached response
# If key is new, process and store the key
Real-world: Network timeout during POST — did the record get created or not? Without idempotency, retrying creates a duplicate. With an idempotency key, the server says "already processed, here's the original response."
Follow-up: Is GET idempotent? Is DELETE idempotent? — GET: yes (same response). DELETE: yes (deleting twice = same final state). POST: no (creates new resource each time). PUT: yes (replaces same resource).
Q12. How do you handle partial failures (5 out of 10 batches fail)?
ScenarioCode

Never let one batch failure abort the entire sync. Strategy: (1) Process all batches independently. (2) Track success/failure per batch. (3) Failed records go to a dead letter queue (DLQ) or error table. (4) After the main run, retry the failed batches once. (5) If still failing, generate a failure report with record details and error messages. (6) Alert the support team. The successful records should NOT be rolled back — partial success is better than total failure in most integration scenarios.

def process_with_dlq(records, target_url, headers, batch_size=100):
    dead_letter_queue = []
    success_count = 0

    for i, batch in enumerate(chunk_list(records, batch_size)):
        try:
            resp = requests.post(target_url, json={"records": batch}, headers=headers)
            resp.raise_for_status()
            success_count += len(batch)
        except Exception as e:
            for record in batch:
                dead_letter_queue.append({
                    "record": record,
                    "error": str(e),
                    "batch": i + 1,
                    "timestamp": datetime.now().isoformat(),
                })

    # Retry DLQ once
    if dead_letter_queue:
        print(f"Retrying {len(dead_letter_queue)} failed records...")
        for item in dead_letter_queue[:]:
            try:
                requests.post(target_url, json={"records": [item["record"]]}, headers=headers)
                dead_letter_queue.remove(item)
                success_count += 1
            except:
                pass  # Still failing, keep in DLQ

    return {"success": success_count, "dlq": dead_letter_queue}
Real-world: Syncing 10 batches of 100 employees. Batches 3, 7, 9 fail because target API's DB had a brief lock. Retry succeeds for 3 and 9, but batch 7 has a genuinely bad record. DLQ captures those 100 records for manual review.
Follow-up: Should you roll back successful batches when some fail? — Usually no. In integration, eventual consistency is preferred. Roll back only if business logic requires all-or-nothing (e.g., financial transactions).
Q13. Source API is slow — how do you optimize?
ScenarioCode

Strategies to handle slow source APIs: (1) Parallel requests — fetch multiple pages concurrently using asyncio or ThreadPoolExecutor. (2) Request only what you need — use field filtering (fields=id,name,email) to reduce payload. (3) Compression — send Accept-Encoding: gzip header. (4) Caching — cache responses that don't change often (departments, locations). (5) Incremental sync — fetch only changed records using modified_since parameter. (6) Connection pooling — reuse HTTP connections with requests.Session().

from concurrent.futures import ThreadPoolExecutor, as_completed

def parallel_fetch(base_url, headers, total_pages, max_workers=5):
    all_records = []

    def fetch_page(page):
        session = requests.Session()
        session.headers.update(headers)
        session.headers["Accept-Encoding"] = "gzip"
        resp = session.get(base_url, params={"page": page, "limit": 100,
                                              "fields": "id,name,email,dept"})
        return resp.json()["data"]

    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        futures = {executor.submit(fetch_page, p): p for p in range(1, total_pages + 1)}
        for future in as_completed(futures):
            all_records.extend(future.result())

    return all_records
Real-world: Source API takes 3s per page, 100 pages = 5 minutes sequential. With 5 parallel workers, it drops to ~1 minute. Combined with field filtering (only 4 fields vs 50), payload drops 90%.
Follow-up: How many parallel connections is safe? — Check the API's rate limit docs. Start with 3-5 concurrent. Monitor for 429s. Never exceed the documented limit.
Q14. How do you handle timezone differences between systems?
ScenarioTip

Timezone bugs are subtle and devastating. Rules: (1) Always store in UTC internally. Convert to local time only for display. (2) When reading from source, check if timestamps have timezone info. If not, ask which timezone they assume. (3) Use ISO 8601 format (2024-01-15T10:30:00Z) for all API communication. (4) When comparing timestamps across systems, convert both to UTC first. (5) Be aware of DST (Daylight Saving Time) — India (IST) doesn't have DST but US/Europe do.

from datetime import datetime
from zoneinfo import ZoneInfo

def normalize_to_utc(timestamp_str, source_tz="Asia/Kolkata"):
    # Source sends: "2024-01-15 10:30:00" (IST, no tz info)
    naive = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S")
    local = naive.replace(tzinfo=ZoneInfo(source_tz))
    utc = local.astimezone(ZoneInfo("UTC"))
    return utc.isoformat()  # "2024-01-15T05:00:00+00:00"

# Always compare in UTC
source_time = normalize_to_utc("2024-01-15 10:30:00", "Asia/Kolkata")
target_time = normalize_to_utc("2024-01-15 05:00:00", "UTC")
# These are the same moment!
Real-world: PeopleStrong (IST) syncs to a US payroll system (EST). Employee's shift ends at 18:00 IST (12:30 EST). Without timezone handling, the payroll system records 18:00 EST — 5.5 hours off. Attendance goes wrong.
Follow-up: What about date-only fields like date_of_birth? — Dates without time don't need timezone conversion. Store as plain date (YYYY-MM-DD). Only timestamps need UTC normalization.
Q15. What if target API is down during scheduled sync?
ScenarioCode

Plan for target downtime: (1) Circuit breaker — after N consecutive failures, stop trying and wait. (2) Queue the data — extract from source, save to a local queue (file, Redis, DB), and process when target comes back. (3) Scheduled retry — attempt re-sync every 15 minutes. (4) Alert — notify team if target has been down > 1 hour. (5) SLA tracking — record downtime for vendor accountability. Never lose extracted data just because the target is down.

import json
from pathlib import Path

def sync_with_queue(source_url, target_url, queue_dir="./sync_queue"):
    Path(queue_dir).mkdir(exist_ok=True)

    # Extract from source (always works)
    records = fetch_all_offset(source_url, headers={})

    # Try to load to target
    try:
        result = batch_upload(records, target_url, headers={})
        print(f"Sync success: {result['success']} records")
    except requests.exceptions.ConnectionError:
        # Target is down - queue the data
        queue_file = Path(queue_dir) / f"pending_{datetime.now():%Y%m%d_%H%M%S}.json"
        queue_file.write_text(json.dumps(records))
        print(f"Target down! Queued {len(records)} records to {queue_file}")
        send_alert("Target API down during scheduled sync")

def process_queue(target_url, queue_dir="./sync_queue"):
    for f in sorted(Path(queue_dir).glob("pending_*.json")):
        records = json.loads(f.read_text())
        try:
            batch_upload(records, target_url, headers={})
            f.rename(f.with_suffix(".done"))
        except:
            break  # Still down, stop retrying
Real-world: 2 AM sync runs. Target payroll API is in maintenance till 4 AM. Your pipeline extracts data successfully, queues it locally. At 4:15 AM, the retry cron picks up the queue and loads the data. Zero data loss.
Follow-up: What if the queue grows for days? — Set a max queue age. If data is >48 hours old, it may be stale. Alert for manual intervention.
Q16. How do you implement a dead letter queue for failed records?
CodeScenario

A Dead Letter Queue (DLQ) captures records that fail processing after all retries are exhausted. It preserves the original data, the error reason, and metadata (timestamp, attempt count) so that support can investigate and reprocess. Implement as: (1) A database table with columns for record data, error, attempt_count, created_at, status. (2) A retry mechanism that picks up DLQ items periodically. (3) A dashboard to view and manually reprocess failed records. (4) Max retry limit per record to prevent infinite loops.

import sqlite3

def init_dlq(db_path="dlq.db"):
    conn = sqlite3.connect(db_path)
    conn.execute("""CREATE TABLE IF NOT EXISTS dead_letter_queue (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        record_data TEXT NOT NULL,
        error_message TEXT,
        source_system TEXT,
        target_system TEXT,
        attempt_count INTEGER DEFAULT 1,
        max_attempts INTEGER DEFAULT 3,
        status TEXT DEFAULT 'pending',
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
        last_retry_at TIMESTAMP
    )""")
    conn.commit()
    return conn

def add_to_dlq(conn, record, error, source, target):
    conn.execute(
        "INSERT INTO dead_letter_queue (record_data, error_message, source_system, target_system) VALUES (?, ?, ?, ?)",
        (json.dumps(record), str(error), source, target)
    )
    conn.commit()

def retry_dlq(conn, target_url, headers):
    rows = conn.execute(
        "SELECT id, record_data, attempt_count, max_attempts FROM dead_letter_queue WHERE status='pending' AND attempt_count < max_attempts"
    ).fetchall()
    for row_id, data, attempts, max_att in rows:
        try:
            record = json.loads(data)
            requests.post(target_url, json=record, headers=headers).raise_for_status()
            conn.execute("UPDATE dead_letter_queue SET status='resolved' WHERE id=?", (row_id,))
        except Exception as e:
            new_status = "failed" if attempts + 1 >= max_att else "pending"
            conn.execute("UPDATE dead_letter_queue SET attempt_count=?, status=?, last_retry_at=CURRENT_TIMESTAMP WHERE id=?",
                        (attempts + 1, new_status, row_id))
    conn.commit()
Real-world: 50 out of 5000 employee records fail because their department codes don't exist in the target system. DLQ captures them. Admin creates the missing departments, then manually triggers DLQ retry — all 50 process successfully.
Follow-up: DLQ vs error log? — Error log is just for viewing. DLQ preserves the original data so you can reprocess it without re-extracting from source.
Q17. How do you validate data between source and target?
ScenarioCode

Reconciliation ensures data integrity after sync. Checks: (1) Count match — source records = target records. (2) Checksum match — hash of key fields should be identical. (3) Sample validation — randomly pick 10 records and compare field by field. (4) Aggregate validation — SUM(salary) in source should equal SUM(salary) in target. (5) Null check — no unexpected nulls in required fields. Run reconciliation after every sync and alert on mismatches.

def reconcile(source_records, target_records, key_field="employee_id"):
    report = {"source_count": len(source_records), "target_count": len(target_records)}

    source_keys = {r[key_field] for r in source_records}
    target_keys = {r[key_field] for r in target_records}

    report["missing_in_target"] = source_keys - target_keys
    report["extra_in_target"] = target_keys - source_keys
    report["count_match"] = len(source_keys) == len(target_keys)

    # Sample field-level comparison
    source_map = {r[key_field]: r for r in source_records}
    target_map = {r[key_field]: r for r in target_records}
    mismatches = []
    for key in list(source_keys & target_keys)[:10]:  # Sample 10
        for field in source_map[key]:
            if source_map[key].get(field) != target_map[key].get(field):
                mismatches.append({"key": key, "field": field,
                    "source": source_map[key].get(field),
                    "target": target_map[key].get(field)})
    report["mismatches"] = mismatches
    return report
Real-world: After syncing 5000 employees, reconciliation finds 4998 in target. 2 records are missing — they had special characters in names that the target API rejected silently (200 OK but didn't actually save).
Follow-up: How often should you run reconciliation? — After every sync (automated). Plus a weekly full reconciliation that compares ALL records, not just the delta.
Q18. What is webhook vs polling? When to use which?
ScenarioTip

Polling: Your system periodically calls the source API to check for new data (e.g., every 5 min). Simple but wasteful — most calls return nothing. Webhook: Source system calls YOUR API when something changes (event-driven). Real-time, efficient, but you need an exposed endpoint. Use polling when: source doesn't support webhooks, you need batch processing, data changes are frequent. Use webhooks when: you need real-time updates, source supports them, you can expose an endpoint. Best practice: webhook with polling as fallback.

# Polling approach
import schedule

def poll_for_changes():
    last_sync = load_last_sync_time()
    records = requests.get(
        "https://api.source.com/employees",
        params={"modified_since": last_sync}
    ).json()
    if records:
        process_records(records)
    save_last_sync_time(datetime.utcnow())

schedule.every(5).minutes.do(poll_for_changes)

# Webhook approach (FastAPI)
from fastapi import FastAPI, Request
app = FastAPI()

@app.post("/webhook/employee-update")
async def handle_webhook(request: Request):
    payload = await request.json()
    # Verify webhook signature
    if not verify_signature(request.headers, payload):
        return {"error": "Invalid signature"}, 401
    process_records(payload["data"])
    return {"status": "received"}
Real-world: PeopleStrong fires a webhook when an employee is terminated. Your benefits system receives it in real-time and immediately disables the employee's insurance. With polling (every 5 min), there's a 5-min window where a terminated employee could still access benefits.
Follow-up: What if webhook delivery fails? — Implement webhook retry (source retries 3 times) + reconciliation polling (catch anything webhooks missed). This is the "belt and suspenders" approach.
Q19. How do you handle API versioning during integration?
ScenarioTip

API versioning strategies: (1) URL versioning: /api/v1/employees vs /api/v2/employees. (2) Header versioning: Accept: application/vnd.api+json;version=2. (3) Query param: ?version=2. Best practices for integration: Always pin to a specific version. Subscribe to the API provider's changelog. Test against new versions in staging before switching. Use an adapter pattern — isolate API-specific code so version changes only affect one file.

# Adapter pattern for API versioning
class EmployeeAPIv1:
    def get_employees(self, modified_since=None):
        resp = requests.get("https://api.example.com/v1/employees",
                           params={"updated_after": modified_since})
        data = resp.json()
        return [{"id": r["emp_code"], "name": r["fullname"]} for r in data]

class EmployeeAPIv2:
    def get_employees(self, modified_since=None):
        resp = requests.get("https://api.example.com/v2/employees",
                           params={"modified_since": modified_since})
        data = resp.json()["results"]  # v2 wraps in "results"
        return [{"id": r["employee_id"], "name": f"{r['first']} {r['last']}"} for r in data]

# Config-driven version selection
API_VERSION = os.environ.get("EMPLOYEE_API_VERSION", "v2")
api = EmployeeAPIv2() if API_VERSION == "v2" else EmployeeAPIv1()
Real-world: PeopleStrong deprecates v1 API in 3 months. You have 10 integrations using v1. With adapter pattern, you write EmployeeAPIv2 adapter, test it, and switch by changing one env variable. Without it, you'd change 10 files.
Follow-up: What if v1 and v2 are running simultaneously during migration? — Run both in parallel, compare outputs, switch to v2 only after outputs match for 1 week. This is "parallel run" testing.
Q20. What headers are important in API calls?
Tip

Essential headers: (1) Authorization: Bearer token or API key. (2) Content-Type: application/json (tells server what you're sending). (3) Accept: application/json (tells server what you expect). (4) Accept-Encoding: gzip (enables compression). (5) User-Agent: identifies your client. (6) X-Request-ID: unique ID for tracing. (7) Retry-After: response header telling you when to retry. (8) ETag / If-None-Match: for caching. (9) Idempotency-Key: for safe retries on POST.

headers = {
    "Authorization": "Bearer eyJhbGciOi...",
    "Content-Type": "application/json",
    "Accept": "application/json",
    "Accept-Encoding": "gzip",
    "User-Agent": "PeopleStrong-Integration/1.0",
    "X-Request-ID": "sync-20240115-abc123",
    "Idempotency-Key": "emp-batch-20240115-001",
}
Interview tip: Mentioning X-Request-ID and Idempotency-Key shows you think about production concerns (debugging, reliability). Most candidates only mention Authorization and Content-Type.
Follow-up: What's the difference between Content-Type and Accept? — Content-Type describes what you're SENDING. Accept describes what you want to RECEIVE. A POST might send JSON but accept XML.
Q21. How do you handle large file downloads via API?
CodeScenario

For large files (>100MB), never load the entire response into memory. Use streaming: (1) Set stream=True in requests. (2) Iterate over response chunks. (3) Write each chunk to disk. (4) Track progress with content-length header. (5) Support range requests (Resume download if it fails mid-way). (6) Verify integrity with checksum after download.

def download_large_file(url, output_path, headers, chunk_size=8192):
    with requests.get(url, headers=headers, stream=True, timeout=300) as resp:
        resp.raise_for_status()
        total = int(resp.headers.get("content-length", 0))
        downloaded = 0
        with open(output_path, "wb") as f:
            for chunk in resp.iter_content(chunk_size=chunk_size):
                f.write(chunk)
                downloaded += len(chunk)
                if total:
                    pct = (downloaded / total) * 100
                    print(f"\rProgress: {pct:.1f}%", end="")
    print(f"\nDownloaded {downloaded} bytes to {output_path}")
Real-world: Source API returns a 500MB employee photo archive as a ZIP. Without streaming, Python tries to load 500MB into RAM and crashes. With streaming, it writes chunk by chunk using only 8KB of RAM.
Follow-up: How do you resume a failed download? — Use Range header: Range: bytes=1000000- to resume from byte 1MB. Check if the server supports range requests (Accept-Ranges: bytes in response).
Q22. What is the circuit breaker pattern?
ScenarioCode

A circuit breaker prevents cascading failures by stopping calls to a failing service. Three states: (1) Closed (normal) — requests flow through. (2) Open (tripped) — requests fail immediately without calling the service, after N consecutive failures. (3) Half-open — after a timeout, allow one test request. If it succeeds, go back to Closed; if it fails, stay Open. This protects both your system (from hanging on timeouts) and the failing service (from being overwhelmed by retries).

class CircuitBreaker:
    def __init__(self, failure_threshold=5, recovery_timeout=60):
        self.failure_count = 0
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.state = "closed"
        self.last_failure_time = 0

    def call(self, func, *args, **kwargs):
        if self.state == "open":
            if time.time() - self.last_failure_time > self.recovery_timeout:
                self.state = "half-open"
            else:
                raise Exception("Circuit is OPEN - service unavailable")

        try:
            result = func(*args, **kwargs)
            self.failure_count = 0
            self.state = "closed"
            return result
        except Exception as e:
            self.failure_count += 1
            self.last_failure_time = time.time()
            if self.failure_count >= self.failure_threshold:
                self.state = "open"
                print(f"Circuit OPENED after {self.failure_count} failures")
            raise

# Usage
breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60)
try:
    result = breaker.call(requests.get, "https://api.target.com/employees")
except:
    print("Using cached data or fallback")
Real-world: Target API goes down. Without circuit breaker, your 100 batch requests each wait 30s for timeout = 50 minutes wasted. With circuit breaker, after 5 failures it opens the circuit — remaining 95 batches fail instantly. You queue them and retry in 60 seconds.
Follow-up: What libraries implement circuit breaker? — Python: pybreaker, tenacity. Java: Resilience4j, Hystrix (deprecated). The pattern is also built into service meshes like Istio.
Q23. How do you handle concurrent API calls?
CodeScenario

Use asyncio (Python) or ThreadPoolExecutor for concurrent calls. Key concerns: (1) Rate limiting — use a semaphore to cap concurrent requests. (2) Connection pooling — reuse connections with aiohttp.ClientSession. (3) Error isolation — one failed request shouldn't crash others. (4) Order preservation — if order matters, use asyncio.gather() which preserves input order. (5) Resource limits — don't open 10,000 connections; use a bounded semaphore.

import asyncio
import aiohttp

async def fetch_page(session, url, page, semaphore):
    async with semaphore:
        async with session.get(url, params={"page": page, "limit": 100}) as resp:
            data = await resp.json()
            return data["records"]

async def fetch_all_concurrent(url, total_pages, max_concurrent=5):
    semaphore = asyncio.Semaphore(max_concurrent)
    async with aiohttp.ClientSession() as session:
        tasks = [fetch_page(session, url, p, semaphore) for p in range(1, total_pages + 1)]
        results = await asyncio.gather(*tasks, return_exceptions=True)

    all_records = []
    errors = []
    for i, result in enumerate(results):
        if isinstance(result, Exception):
            errors.append({"page": i + 1, "error": str(result)})
        else:
            all_records.extend(result)

    return all_records, errors

# Run it
records, errors = asyncio.run(fetch_all_concurrent("https://api.source.com/employees", 100))
Real-world: Fetching 100 pages at 2s each = 200s sequential. With 5 concurrent workers: ~40s. The semaphore ensures you never exceed 5 simultaneous connections, respecting the API's rate limit.
Follow-up: asyncio vs threading? — asyncio is better for I/O-bound tasks (API calls, file reads). Threading has GIL overhead. asyncio handles thousands of concurrent connections efficiently.
Q24. What is API throttling and how do you manage it?
ScenarioCode

Throttling is limiting the rate of your outgoing requests to respect the target API's limits. Different from rate limiting (which is the server rejecting excess requests). You implement throttling proactively to avoid getting rate-limited. Methods: (1) Fixed delay between requests. (2) Token bucket — allow bursts but cap average rate. (3) Leaky bucket — smooth, constant rate. (4) Read the API's rate limit headers (X-RateLimit-Remaining) and adjust dynamically.

import time, threading

class TokenBucket:
    def __init__(self, rate, capacity):
        self.rate = rate            # tokens per second
        self.capacity = capacity    # max burst
        self.tokens = capacity
        self.last_refill = time.monotonic()
        self.lock = threading.Lock()

    def acquire(self):
        with self.lock:
            now = time.monotonic()
            elapsed = now - self.last_refill
            self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
            self.last_refill = now
            if self.tokens >= 1:
                self.tokens -= 1
                return True
            return False

    def wait_and_acquire(self):
        while not self.acquire():
            time.sleep(0.05)

# 10 requests per second, burst of 20
bucket = TokenBucket(rate=10, capacity=20)

def throttled_request(url):
    bucket.wait_and_acquire()
    return requests.get(url)
Real-world: Target API allows 100 requests/minute. Your sync has 500 batches. Without throttling, you blast 500 requests in 10 seconds and get 429'd for the next 50 seconds. With token bucket (100/min = 1.67/sec), all 500 complete smoothly in 5 minutes.
Follow-up: Adaptive throttling? — Read X-RateLimit-Remaining header after each call. If remaining < 10%, slow down. If remaining > 50%, speed up. This maximizes throughput while staying safe.
Q25. How do you do reconciliation after API sync?
CodeScenario

Post-sync reconciliation is the final verification step. Full process: (1) Count check: SELECT COUNT(*) from both systems. (2) Key check: all source keys exist in target. (3) Value check: sample records match field-by-field. (4) Aggregate check: SUM/AVG of numeric fields match. (5) Freshness check: latest record in target matches latest in source. (6) Generate a reconciliation report. (7) Alert on any discrepancy. This should be automated and run after every sync.

def full_reconciliation(source_url, target_url, headers):
    # Fetch counts
    src_count = requests.get(f"{source_url}/count", headers=headers).json()["total"]
    tgt_count = requests.get(f"{target_url}/count", headers=headers).json()["total"]

    report = {
        "timestamp": datetime.utcnow().isoformat(),
        "source_count": src_count,
        "target_count": tgt_count,
        "count_match": src_count == tgt_count,
        "checks": [],
    }

    # Sample validation (10 random records)
    import random
    src_records = requests.get(source_url, params={"limit": 1000}, headers=headers).json()
    sample_ids = random.sample([r["id"] for r in src_records], min(10, len(src_records)))

    for eid in sample_ids:
        src = requests.get(f"{source_url}/{eid}", headers=headers).json()
        tgt = requests.get(f"{target_url}/{eid}", headers=headers).json()
        match = src["name"] == tgt["name"] and src["email"] == tgt["email"]
        report["checks"].append({"id": eid, "match": match})

    report["status"] = "PASS" if all(c["match"] for c in report["checks"]) and report["count_match"] else "FAIL"
    return report
Real-world: Weekly audit: source has 5000, target has 4995. Reconciliation finds 5 records with special characters (emojis in names) were silently dropped by target. Fix: encode/sanitize names before sending.
Follow-up: What do you do when reconciliation fails? — (1) Identify missing/mismatched records. (2) Check DLQ for those records. (3) Re-extract and re-load just the problematic records. (4) Root-cause the failure for permanent fix.

2 API to SFTP Integration

Q1. How do you fetch data from API and push to SFTP?
CodeScenario

The pipeline: (1) Call the API and collect all records (with pagination handling). (2) Transform data into the required file format (CSV, XML, JSON). (3) Write to a local temp file. (4) Connect to SFTP using paramiko. (5) Upload the file to the correct directory. (6) Verify the upload (check file size on SFTP matches local). (7) Clean up local temp file. (8) Log and notify. Always use a temp file, never stream directly — if SFTP fails mid-upload, you still have the complete local file to retry.

import paramiko, csv, tempfile, os

def api_to_sftp(api_url, sftp_config, remote_path):
    # 1. Fetch from API
    records = fetch_all_offset(api_url, headers={"Authorization": "Bearer xxx"})

    # 2. Write to temp CSV
    with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f:
        writer = csv.DictWriter(f, fieldnames=["emp_id", "name", "email", "department"])
        writer.writeheader()
        writer.writerows(records)
        local_path = f.name

    # 3. Upload to SFTP
    transport = paramiko.Transport((sftp_config["host"], sftp_config["port"]))
    transport.connect(username=sftp_config["user"], password=sftp_config["password"])
    sftp = paramiko.SFTPClient.from_transport(transport)

    try:
        sftp.put(local_path, remote_path)
        # 4. Verify
        remote_size = sftp.stat(remote_path).st_size
        local_size = os.path.getsize(local_path)
        assert remote_size == local_size, f"Size mismatch: {local_size} vs {remote_size}"
        print(f"Uploaded {len(records)} records to {remote_path}")
    finally:
        sftp.close()
        transport.close()
        os.unlink(local_path)
Real-world: PeopleStrong syncs daily employee roster to a legacy payroll system that only accepts CSV via SFTP. Every night at 2 AM, you extract employees, generate CSV, upload to /incoming/ on SFTP server.
Follow-up: What if the SFTP path doesn't exist? — Use sftp.mkdir() to create it, or better yet, check with sftp.stat() first and create if needed.
Q2. How do you generate CSV from API response?
CodeTip

Use Python's csv module with DictWriter for clean, consistent output. Key considerations: (1) Handle nested JSON by flattening (address.city becomes address_city). (2) Handle None values (write empty string, not "None"). (3) Handle special characters (commas, quotes, newlines in data — CSV module handles quoting automatically). (4) Set proper encoding (UTF-8 with BOM if Excel compatibility needed). (5) Use consistent date format in output.

import csv
from datetime import datetime

def flatten_record(record, parent_key="", sep="_"):
    """Flatten nested dict: {address: {city: 'Delhi'}} -> {address_city: 'Delhi'}"""
    items = []
    for k, v in record.items():
        new_key = f"{parent_key}{sep}{k}" if parent_key else k
        if isinstance(v, dict):
            items.extend(flatten_record(v, new_key, sep).items())
        else:
            items.append((new_key, v if v is not None else ""))
    return dict(items)

def generate_csv(records, output_path, encoding="utf-8-sig"):
    # utf-8-sig adds BOM for Excel compatibility
    flat_records = [flatten_record(r) for r in records]
    fieldnames = list(flat_records[0].keys()) if flat_records else []
    with open(output_path, "w", newline="", encoding=encoding) as f:
        writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
        writer.writeheader()
        writer.writerows(flat_records)
    return output_path
Interview tip: Mention BOM (Byte Order Mark) for Excel compatibility — this shows attention to detail. Also mention handling nested JSON — real API responses are never flat.
Follow-up: What if some records have extra fields? — Use extrasaction='ignore' in DictWriter. Or dynamically build fieldnames from ALL records, not just the first one.
Q3. How do you handle large datasets (memory issues)?
CodeScenario

For large datasets (100K+ records), never load everything into memory. Use streaming/generator pattern: (1) Fetch data page by page from API (generator function). (2) Write each page to CSV immediately (don't accumulate). (3) Use context managers to ensure files are properly closed. (4) For very large files, split into multiple files (e.g., 50K records per file). (5) Monitor memory usage with tracemalloc during testing.

def stream_api_pages(url, headers):
    """Generator that yields records page by page"""
    page = 1
    while True:
        resp = requests.get(url, params={"page": page, "limit": 500}, headers=headers)
        data = resp.json()["data"]
        if not data:
            break
        yield from data  # Yield individual records
        page += 1

def stream_to_csv(url, headers, output_path, fieldnames):
    """Write API data to CSV without loading all into memory"""
    with open(output_path, "w", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
        writer.writeheader()
        count = 0
        for record in stream_api_pages(url, headers):
            writer.writerow(record)
            count += 1
            if count % 10000 == 0:
                print(f"Written {count} records...")
    print(f"Total: {count} records")
Real-world: 500K employee attendance records for the month. Loading all at once = 2GB RAM, Python crashes on a 1GB container. With streaming, only one page (500 records, ~100KB) is in memory at a time.
Follow-up: What if you need to sort the data before writing? — Ask the API to return sorted data (sort=name). If API doesn't support sorting, use external sort (write to temp files, merge-sort).
Q4. File naming conventions for SFTP
TipScenario

Good file naming prevents conflicts and enables automation. Convention: {source}_{type}_{date}_{sequence}.{ext}. Examples: peoplestrong_employees_20240115_001.csv, hrms_attendance_20240115_full.csv. Rules: (1) Include date for traceability. (2) Include sequence number for multiple files per day. (3) Use lowercase, underscores (no spaces). (4) Upload with a temp extension (.tmp), then rename to final — this prevents the consumer from reading a partial file. (5) Include load type (full/delta) in the name.

from datetime import datetime

def generate_filename(source, data_type, load_type="full", ext="csv"):
    date_str = datetime.now().strftime("%Y%m%d")
    timestamp = datetime.now().strftime("%H%M%S")
    return f"{source}_{data_type}_{date_str}_{load_type}_{timestamp}.{ext}"

# Safe upload pattern: write as .tmp, then rename
def safe_sftp_upload(sftp, local_path, remote_dir, filename):
    temp_remote = f"{remote_dir}/.{filename}.tmp"
    final_remote = f"{remote_dir}/{filename}"
    sftp.put(local_path, temp_remote)
    sftp.rename(temp_remote, final_remote)  # Atomic rename
    return final_remote

# Result: peoplestrong_employees_20240115_full_023000.csv
Real-world: The consumer system has a file watcher. If you upload a 500MB file slowly, the watcher triggers at 50% and processes a partial file. With .tmp extension + rename, the watcher only sees the complete file.
Follow-up: What if the consumer needs a specific filename format? — Make the naming pattern configurable. Store it in a config file: pattern: "{type}_{YYYYMMDD}.csv"
Q5. How do you compress files before upload?
Code

Compression reduces upload time and storage. Use gzip for single files (most universally supported), zip for multiple files. For a 100MB CSV, gzip typically achieves 85-90% compression (down to 10-15MB). Always compress before upload, not during (avoid streaming compression to SFTP). Name the file with .gz or .zip extension so the consumer knows it's compressed.

import gzip, shutil, zipfile

def gzip_file(input_path):
    output_path = f"{input_path}.gz"
    with open(input_path, "rb") as f_in:
        with gzip.open(output_path, "wb") as f_out:
            shutil.copyfileobj(f_in, f_out)
    return output_path

def zip_files(file_paths, output_path):
    with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
        for fp in file_paths:
            zf.write(fp, os.path.basename(fp))
    return output_path

# Usage
compressed = gzip_file("employees_20240115.csv")
# employees_20240115.csv (100MB) -> employees_20240115.csv.gz (12MB)
Real-world: Monthly attendance file is 200MB CSV. Over a slow VPN to SFTP (10 Mbps), uncompressed upload takes 2.5 minutes. Gzipped to 20MB, it uploads in 16 seconds.
Follow-up: Does the consumer need to decompress? — Yes, document it. Some SFTP consumers auto-decompress .gz files. If not, provide a decompression script or use password-protected ZIP.
Q6. How do you verify file integrity after upload?
CodeScenario

Verify with: (1) File size match — local size == remote size. (2) MD5/SHA256 checksum — compute hash locally, compare with hash of remote file. (3) Row count validation — upload a control file (.ctl) alongside the data file containing the expected row count. (4) Download and compare — re-download the file and diff with local (expensive but thorough). The control file approach is most common in enterprise SFTP integrations.

import hashlib

def compute_md5(file_path):
    md5 = hashlib.md5()
    with open(file_path, "rb") as f:
        for chunk in iter(lambda: f.read(8192), b""):
            md5.update(chunk)
    return md5.hexdigest()

def upload_with_verification(sftp, local_path, remote_path):
    # Upload data file
    sftp.put(local_path, remote_path)

    # Size check
    local_size = os.path.getsize(local_path)
    remote_size = sftp.stat(remote_path).st_size
    assert local_size == remote_size, f"Size mismatch!"

    # Upload control file with checksum + row count
    checksum = compute_md5(local_path)
    row_count = sum(1 for _ in open(local_path)) - 1  # Minus header
    ctl_path = remote_path.replace(".csv", ".ctl")
    with sftp.open(ctl_path, "w") as ctl:
        ctl.write(f"filename={os.path.basename(remote_path)}\n")
        ctl.write(f"row_count={row_count}\n")
        ctl.write(f"md5={checksum}\n")
        ctl.write(f"timestamp={datetime.utcnow().isoformat()}\n")
    print(f"Verified: {row_count} rows, MD5: {checksum}")
Real-world: Network hiccup during upload corrupts the file. Consumer loads 4000 rows instead of 5000 — 1000 employees don't get paid. With a .ctl file, the consumer's loader checks row count first and rejects partial files.
Follow-up: What is a trigger file? — An empty file (e.g., employees.done) uploaded AFTER the data file. Consumer's watcher looks for .done file, not .csv. Guarantees data file is complete before processing starts.
Q7. SFTP connection keeps dropping — how do you handle?
CodeScenario

SFTP drops happen due to network issues, firewall timeouts, or server-side limits. Solutions: (1) Keep-alive — send periodic packets to keep the connection alive. (2) Auto-reconnect — wrap SFTP operations in a retry decorator that re-establishes the connection. (3) Chunked upload — for large files, upload in chunks so you can resume from where you left off. (4) Connection pooling — reuse connections across uploads. (5) Set timeout values appropriately (not too short, not too long).

class ResilientSFTP:
    def __init__(self, host, port, user, password):
        self.config = {"host": host, "port": port, "user": user, "password": password}
        self.transport = None
        self.sftp = None

    def connect(self):
        self.transport = paramiko.Transport((self.config["host"], self.config["port"]))
        self.transport.connect(username=self.config["user"], password=self.config["password"])
        self.transport.set_keepalive(30)  # Send keepalive every 30s
        self.sftp = paramiko.SFTPClient.from_transport(self.transport)

    def upload(self, local_path, remote_path, max_retries=3):
        for attempt in range(max_retries):
            try:
                if not self.transport or not self.transport.is_active():
                    self.connect()
                self.sftp.put(local_path, remote_path)
                return True
            except (paramiko.SSHException, OSError) as e:
                print(f"Connection lost (attempt {attempt+1}): {e}")
                time.sleep(2 ** attempt)
                self.connect()  # Reconnect
        raise Exception("SFTP upload failed after max retries")
Real-world: SFTP server behind a corporate firewall that kills idle connections after 60 seconds. Your 200MB upload takes 3 minutes. Without keepalive, connection drops at 60s. With keepalive(30), packets keep it alive throughout.
Follow-up: What is the difference between SSH keepalive and TCP keepalive? — SSH keepalive is application-level (paramiko sends packets). TCP keepalive is OS-level. Use SSH keepalive for SFTP — it's more reliable and configurable.
Q8. How do you schedule daily file generation?
CodeScenario

Options: (1) Cron job (Linux) — most reliable for simple schedules. (2) APScheduler (Python) — in-process scheduler. (3) Airflow/Prefect — for complex DAGs with dependencies, retries, alerting. (4) Cloud schedulers — AWS EventBridge + Lambda, Azure Functions timer trigger. For production integration pipelines, use Airflow or equivalent — it provides retry, monitoring, backfill, and dependency management.

# Option 1: Cron (crontab -e)
# Run every day at 2 AM
# 0 2 * * * /usr/bin/python3 /opt/integrations/api_to_sftp.py >> /var/log/sync.log 2>&1

# Option 2: APScheduler
from apscheduler.schedulers.blocking import BlockingScheduler

scheduler = BlockingScheduler()

@scheduler.scheduled_job("cron", hour=2, minute=0)
def daily_sync():
    try:
        api_to_sftp(
            api_url="https://api.peoplestrong.com/employees",
            sftp_config={"host": "sftp.payroll.com", "port": 22,
                         "user": "integration", "password": os.environ["SFTP_PASS"]},
            remote_path="/incoming/employees_daily.csv"
        )
        send_notification("Daily sync completed successfully")
    except Exception as e:
        send_alert(f"Daily sync FAILED: {e}")

scheduler.start()
Real-world: Payroll cutoff is 6 AM. Your sync must complete by 5 AM. You schedule at 2 AM, giving 3 hours buffer. If it fails at 2 AM, retry at 3 AM. If still failing at 4 AM, alert the on-call engineer.
Follow-up: Why not just use time.sleep() in a loop? — It doesn't survive process restarts, has no retry logic, no monitoring, and time drift. Use a proper scheduler.
Q9. What is the difference between SFTP and FTP?
Tip

FTP (File Transfer Protocol): Unencrypted, uses port 21, transmits credentials in plain text. Insecure — never use for sensitive data. FTPS: FTP + TLS/SSL encryption. Better but complex (multiple ports, firewall issues). SFTP (SSH File Transfer Protocol): Runs over SSH (port 22), encrypted by default, single port, supports key-based auth. Always use SFTP for integration — it's secure, simple, and firewall-friendly. SFTP is NOT "FTP over SSH" — it's a completely different protocol that happens to use SSH as its transport.

Interview tip: Many candidates confuse SFTP with FTPS. SFTP = SSH-based (port 22, one connection). FTPS = FTP + SSL (port 990/21, multiple connections). Know the difference — it's a common interview question.
Follow-up: Can you use key-based auth with SFTP? — Yes, use SSH key pairs. Generate with ssh-keygen, share public key with SFTP server admin. More secure than passwords, no credential rotation needed.
Q10. How do you handle sensitive data in files (PII masking)?
CodeScenario

PII (Personally Identifiable Information) must be protected. Techniques: (1) Masking — replace part of data (email: j***@example.com). (2) Tokenization — replace with a random token, store mapping separately. (3) Encryption — encrypt PII fields, decrypt at destination. (4) Hashing — one-way hash for fields that need matching but not reading. (5) Column-level filtering — don't include PII columns if not needed. Always log what was masked and maintain an audit trail.

import re, hashlib

def mask_email(email):
    if not email or "@" not in email:
        return email
    local, domain = email.split("@")
    return f"{local[0]}{'*' * (len(local)-1)}@{domain}"

def mask_phone(phone):
    return re.sub(r"\d(?=\d{4})", "*", phone)  # Show last 4 digits

def mask_aadhaar(aadhaar):
    return f"XXXX-XXXX-{aadhaar[-4:]}"

def mask_record(record):
    masked = record.copy()
    if "email" in masked: masked["email"] = mask_email(masked["email"])
    if "phone" in masked: masked["phone"] = mask_phone(masked["phone"])
    if "aadhaar" in masked: masked["aadhaar"] = mask_aadhaar(masked["aadhaar"])
    return masked
Real-world: PeopleStrong sends employee data to an external vendor for training allocation. The vendor doesn't need Aadhaar numbers or bank details. You mask Aadhaar, exclude bank_account, and only include name, email (masked), department.
Follow-up: What regulations govern PII handling in India? — DPDP Act (Digital Personal Data Protection). Requires consent, purpose limitation, data minimization. Heavy fines for breach.
Q11. How do you handle file already exists on SFTP?
Code

Options: (1) Overwrite — sftp.put() overwrites by default. Use when today's file should replace yesterday's. (2) Version — append timestamp to filename, never overwrite. (3) Archive first — move existing file to /archive/ before uploading new one. (4) Skip — if file exists with same hash, don't re-upload. (5) Error — fail and alert if unexpected file exists. Choice depends on business rules.

def upload_with_archive(sftp, local_path, remote_dir, filename):
    remote_path = f"{remote_dir}/{filename}"
    archive_dir = f"{remote_dir}/archive"

    # Check if file exists
    try:
        sftp.stat(remote_path)
        # File exists - archive it
        try: sftp.mkdir(archive_dir)
        except: pass
        ts = datetime.now().strftime("%Y%m%d_%H%M%S")
        archive_path = f"{archive_dir}/{filename}.{ts}"
        sftp.rename(remote_path, archive_path)
        print(f"Archived existing file to {archive_path}")
    except FileNotFoundError:
        pass  # No existing file, proceed

    sftp.put(local_path, remote_path)
Real-world: Consumer didn't pick up yesterday's file (their system was down). Today's upload would overwrite it, losing yesterday's data. With archive-first, yesterday's file is safe in /archive/.
Follow-up: How do you clean up old archives? — Retention policy: delete archives older than 30 days. Run a cleanup script weekly.
Q12. How do you send email notification after successful upload?
Code

Notifications are essential for monitoring. Send emails with: (1) Status (success/failure). (2) File details (name, size, row count). (3) Duration. (4) Error details if failed. Use Python's smtplib or a service like SendGrid/SES. For critical pipelines, also send to Slack/Teams. Always notify on both success AND failure — silence should be treated as failure.

import smtplib
from email.mime.text import MIMEText

def send_notification(subject, body, recipients):
    msg = MIMEText(body, "html")
    msg["Subject"] = subject
    msg["From"] = "integration@company.com"
    msg["To"] = ", ".join(recipients)

    with smtplib.SMTP("smtp.company.com", 587) as server:
        server.starttls()
        server.login("integration@company.com", os.environ["SMTP_PASS"])
        server.sendmail(msg["From"], recipients, msg.as_string())

# After successful upload
send_notification(
    subject="[SUCCESS] Employee Sync - 2024-01-15",
    body="""<h3>Daily Employee Sync Report</h3>
    <ul>
        <li>Records: 5,234</li>
        <li>File: employees_20240115_full.csv (2.3 MB)</li>
        <li>Duration: 45 seconds</li>
        <li>Status: Uploaded to SFTP successfully</li>
    </ul>""",
    recipients=["payroll-team@company.com"]
)
Real-world: Payroll team needs confirmation that the file is ready for processing. An automated email at 2:30 AM lets them see the status first thing in the morning.
Follow-up: What if the email service is down? — Log the notification failure but don't fail the entire pipeline. The file was uploaded successfully; email is secondary. Retry email separately.
Q13. What is PGP encryption for files?
CodeScenario

PGP (Pretty Good Privacy) encrypts files so only the intended recipient can read them. In SFTP integrations, even though SFTP encrypts data in transit, PGP encrypts data at rest on the SFTP server. Process: (1) Recipient gives you their public key. (2) You encrypt the file with their public key. (3) Upload encrypted file (.pgp/.gpg extension). (4) Recipient decrypts with their private key. This is mandatory for financial data, PII, and compliance (SOX, HIPAA, PCI).

import gnupg

def pgp_encrypt_file(input_path, recipient_key_id, output_path=None):
    gpg = gnupg.GPG()
    output_path = output_path or f"{input_path}.pgp"

    with open(input_path, "rb") as f:
        encrypted = gpg.encrypt_file(
            f,
            recipients=[recipient_key_id],
            output=output_path,
            always_trust=True,
            armor=False  # Binary output (smaller)
        )

    if encrypted.ok:
        print(f"Encrypted: {input_path} -> {output_path}")
        return output_path
    raise Exception(f"Encryption failed: {encrypted.status}")

# Full pipeline: generate CSV -> encrypt -> upload to SFTP
csv_path = generate_csv(records, "employees.csv")
pgp_path = pgp_encrypt_file(csv_path, "payroll-team@company.com")
safe_sftp_upload(sftp, pgp_path, "/incoming", "employees.csv.pgp")
Real-world: PeopleStrong sends salary data to a banking partner for direct deposit. The bank requires PGP encryption — even if someone gains access to the SFTP server, they can't read the salary data without the bank's private key.
Follow-up: What is the difference between PGP and SSL? — SSL encrypts data in transit (between you and server). PGP encrypts data at rest (the file itself). You need BOTH: SFTP (SSL/SSH) for transit + PGP for at-rest encryption.
Q14. How do you handle different file formats (CSV, XML, JSON, Excel)?
Code

Design your pipeline with a formatter abstraction. The core ETL extracts and transforms data into a common format (list of dicts). The final step applies the required output format. This way, adding a new format is just adding one function. Common libraries: csv (CSV), xml.etree (XML), json (JSON), openpyxl (Excel). Let the consumer's requirements dictate the format — don't assume CSV.

import json, csv
import xml.etree.ElementTree as ET
from openpyxl import Workbook

def write_csv(records, path):
    with open(path, "w", newline="") as f:
        w = csv.DictWriter(f, fieldnames=records[0].keys())
        w.writeheader()
        w.writerows(records)

def write_json(records, path):
    Path(path).write_text(json.dumps(records, indent=2, default=str))

def write_xml(records, path, root_tag="employees", row_tag="employee"):
    root = ET.Element(root_tag)
    for record in records:
        row = ET.SubElement(root, row_tag)
        for key, val in record.items():
            child = ET.SubElement(row, key)
            child.text = str(val) if val is not None else ""
    ET.ElementTree(root).write(path, encoding="unicode", xml_declaration=True)

def write_excel(records, path):
    wb = Workbook()
    ws = wb.active
    ws.append(list(records[0].keys()))
    for r in records:
        ws.append(list(r.values()))
    wb.save(path)

# Config-driven format selection
FORMATTERS = {"csv": write_csv, "json": write_json, "xml": write_xml, "xlsx": write_excel}
FORMATTERS[config["output_format"]](records, output_path)
Real-world: Three downstream consumers: Payroll wants CSV, Benefits wants XML, Analytics wants JSON. Same source data, three formatters. Config file controls which format for which consumer.
Follow-up: Which format is best for large datasets? — CSV (smallest size, fastest to parse). JSON is verbose. XML is very verbose. Excel has a 1M row limit. For 500K+ records, always CSV.
Q15. How do you implement checksum validation?
Code

Checksums verify file integrity end-to-end. Process: (1) Compute checksum (MD5 or SHA256) of the local file before upload. (2) Upload the file + checksum (in a .md5 or .sha256 sidecar file). (3) Consumer downloads both files. (4) Consumer computes checksum of downloaded file. (5) Compare: if checksums match, file is intact; if not, re-download. SHA256 is preferred over MD5 for security, but MD5 is sufficient for integrity checks.

import hashlib

def compute_checksum(file_path, algorithm="sha256"):
    h = hashlib.new(algorithm)
    with open(file_path, "rb") as f:
        for chunk in iter(lambda: f.read(65536), b""):
            h.update(chunk)
    return h.hexdigest()

def upload_with_checksum(sftp, local_path, remote_dir):
    filename = os.path.basename(local_path)
    checksum = compute_checksum(local_path)

    # Upload data file
    sftp.put(local_path, f"{remote_dir}/{filename}")

    # Upload checksum sidecar file
    checksum_content = f"{checksum}  {filename}\n"
    with sftp.open(f"{remote_dir}/{filename}.sha256", "w") as f:
        f.write(checksum_content)

    print(f"Uploaded {filename} with SHA256: {checksum}")
Real-world: SFTP server's disk was full during upload. File was truncated at 50MB (original 100MB). Without checksum, consumer processes partial data. With SHA256 sidecar, consumer immediately detects corruption and requests re-upload.
Follow-up: MD5 vs SHA256? — MD5 is faster but has known collision vulnerabilities. SHA256 is slower but cryptographically secure. For file integrity (not security), MD5 is fine. For compliance, use SHA256.

3 SFTP to API Integration

Q1. How do you download file from SFTP and push to API?
CodeScenario

Pipeline: (1) Connect to SFTP. (2) List files in the directory, pick the right one (latest, by naming pattern). (3) Download to local temp file. (4) Parse the file (CSV, XML, etc.). (5) Validate records. (6) Transform to target API's schema. (7) Batch-POST to API. (8) Archive the processed file (move to /processed/ on SFTP). (9) Log results and notify. Key: always archive after processing so you don't reprocess the same file.

def sftp_to_api(sftp_config, remote_dir, api_url, api_headers):
    # Connect to SFTP
    transport = paramiko.Transport((sftp_config["host"], sftp_config["port"]))
    transport.connect(username=sftp_config["user"], password=sftp_config["password"])
    sftp = paramiko.SFTPClient.from_transport(transport)

    # Find latest CSV file
    files = [f for f in sftp.listdir(remote_dir) if f.endswith(".csv")]
    if not files:
        print("No files to process")
        return
    latest = sorted(files)[-1]
    remote_path = f"{remote_dir}/{latest}"

    # Download to temp
    with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as tmp:
        sftp.get(remote_path, tmp.name)
        local_path = tmp.name

    # Parse CSV
    with open(local_path, "r") as f:
        records = list(csv.DictReader(f))

    # Transform and push to API
    result = batch_upload(records, api_url, api_headers)

    # Archive processed file
    archive_path = f"{remote_dir}/processed/{latest}"
    try: sftp.mkdir(f"{remote_dir}/processed")
    except: pass
    sftp.rename(remote_path, archive_path)

    sftp.close()
    transport.close()
    os.unlink(local_path)
    return result
Real-world: A third-party attendance system drops CSV files on SFTP every day at midnight. Your job picks them up at 1 AM, validates, and pushes to PeopleStrong's attendance API. Processed files move to /processed/ so they're never reprocessed.
Follow-up: What if two instances of your job run simultaneously? — Use a lock file on SFTP. Create .lock before processing, delete after. If .lock exists, skip.
Q2. How do you handle corrupt/malformed files?
CodeScenario

Malformed files include: wrong encoding, invalid CSV structure, unexpected delimiters, binary content mixed in, truncated files. Defense: (1) Check file size > 0. (2) Try to parse the header row — if it fails, the file is corrupt. (3) Use try/except on each row. (4) Validate column count matches header count. (5) Move corrupt files to /error/ directory. (6) Alert with details.

def safe_parse_csv(file_path):
    valid_records, errors = [], []
    try:
        with open(file_path, "r", encoding="utf-8") as f:
            first_line = f.readline()
            if not first_line.strip():
                return [], [{"line": 0, "error": "File is empty"}]
            f.seek(0)
            reader = csv.DictReader(f)
            for line_num, row in enumerate(reader, start=2):
                try:
                    if None in row:
                        errors.append({"line": line_num, "error": "Extra columns"})
                        continue
                    if not row.get("emp_id", "").strip():
                        errors.append({"line": line_num, "error": "Missing emp_id"})
                        continue
                    valid_records.append(row)
                except Exception as e:
                    errors.append({"line": line_num, "error": str(e)})
    except UnicodeDecodeError:
        errors.append({"line": 0, "error": "File encoding is not UTF-8"})
    return valid_records, errors
Real-world: Vendor generates CSV on Windows. The file has Latin-1 encoding and BOM characters. Your UTF-8 parser crashes. Fix: try UTF-8, fallback to latin-1, strip BOM.
Follow-up: Should you reject the entire file or process valid rows? — Depends on business rules. For payroll: reject all if >1% errors. For attendance: process valid rows and send error report.
Q3. File has 1000 records, 5 are invalid — how to handle?
ScenarioCode

This is the "partial validity" problem. (1) Process all 1000 records through validation. (2) Separate into valid (995) and invalid (5) buckets. (3) Push 995 valid records to API. (4) Write the 5 invalid records to an error file with reasons. (5) Upload error file to SFTP /errors/. (6) Notification: "995 processed, 5 rejected." (7) If >10% fail, reject the entire file (systemic issue).

def process_with_error_report(file_path, api_url, headers, error_threshold=0.1):
    valid, errors = safe_parse_csv(file_path)
    total = len(valid) + len(errors)
    error_rate = len(errors) / total if total > 0 else 0

    if error_rate > error_threshold:
        raise Exception(f"Error rate {error_rate:.1%} exceeds {error_threshold:.1%}")

    result = batch_upload(valid, api_url, headers)

    if errors:
        error_path = file_path.replace(".csv", "_errors.csv")
        with open(error_path, "w", newline="") as f:
            w = csv.DictWriter(f, fieldnames=["line", "error"])
            w.writeheader()
            w.writerows(errors)
    return {"processed": len(valid), "rejected": len(errors)}
Real-world: Attendance file has 1000 rows. 5 have invalid date formats (31/02/2024). Process 995 valid records, generate error report for 5, email it to the source team.
Follow-up: What threshold is acceptable? — Financial data: 0%. Employee master: 1%. Attendance: 5%. Always discuss thresholds with the business team.
Q4. How do you archive processed files?
Code

Archiving ensures traceability and prevents reprocessing. Pattern: After successful processing, move file from /incoming/ to /processed/YYYY/MM/. Keep a manifest log. Retention policy: 90 days. For failed files, move to /errors/ with timestamp suffix. Never delete without archiving.

def archive_file(sftp, source_path, base_archive_dir):
    now = datetime.now()
    archive_dir = f"{base_archive_dir}/{now:%Y}/{now:%m}"
    for d in [base_archive_dir, f"{base_archive_dir}/{now:%Y}", archive_dir]:
        try: sftp.mkdir(d)
        except: pass
    filename = os.path.basename(source_path)
    archive_path = f"{archive_dir}/{filename}"
    sftp.rename(source_path, archive_path)
    return archive_path
Real-world: Audit team asks "what data was synced on January 15?" You go to /processed/2024/01/ and find the exact file. Without archiving, no way to answer.
Follow-up: How do you clean up old archives? — Monthly cleanup: delete files older than retention period. Log deletions for compliance.
Q5. Multiple files arrive — processing order?
ScenarioTip

Processing order matters when files have dependencies. Strategies: (1) Timestamp sort — process oldest first (FIFO). (2) Filename sort — if files have sequence numbers, process in order. (3) Dependency-based — process departments before employees. (4) Manifest file — a control file lists the correct order. (5) If independent, process in parallel.

def process_files_in_order(sftp, remote_dir, processing_order=None):
    files = sftp.listdir(remote_dir)
    csv_files = [f for f in files if f.endswith(".csv")]
    if processing_order:
        ordered = []
        for pattern in processing_order:
            for f in csv_files:
                if pattern in f:
                    ordered.append(f)
        csv_files = ordered
    else:
        csv_files.sort()
    for filename in csv_files:
        process_single_file(sftp, f"{remote_dir}/{filename}")

# departments must load before employees
process_files_in_order(sftp, "/incoming",
    processing_order=["departments", "designations", "employees"])
Real-world: Source sends departments.csv, designations.csv, employees.csv. Process employees first = API rejects records because referenced department codes don't exist yet.
Follow-up: What if a file in the middle fails? — Stop dependent files. Process departments (OK), designations (FAIL), skip employees. Alert team.
Q6. How do you handle duplicate files?
Code

Detection: (1) Maintain a processed files log (filename + checksum + timestamp). (2) Before processing, check if file's checksum matches a previously processed file. (3) If duplicate, skip and log. (4) Same filename but different content = correction, process it. (5) Same checksum = true duplicate, skip.

def is_duplicate(file_path, processed_log_path="processed_files.json"):
    current_checksum = compute_checksum(file_path)
    if Path(processed_log_path).exists():
        log = json.loads(Path(processed_log_path).read_text())
    else:
        log = {}
    # Check if exact content was already processed
    if current_checksum in [e["checksum"] for e in log.values()]:
        print("DUPLICATE: exact content match, skipping")
        return True
    return False
Real-world: Source team re-uploads the same file because they weren't sure if it succeeded. Without dedup, 5000 employees processed twice creating duplicates.
Follow-up: What if source intentionally re-sends with corrections? — Same filename, different checksum = correction (process it). Same checksum = true duplicate (skip it).
Q7. How do you parse different file formats?
Code

Use a parser factory pattern. Detect format from extension or config. Each parser normalizes data into a list of dicts. Handle format-specific quirks: CSV (delimiter, quoting), XML (namespaces), JSON (array vs object), Excel (sheets, types). Fixed-width is common in legacy systems.

def parse_file(file_path):
    ext = Path(file_path).suffix.lower()
    parsers = {".csv": parse_csv, ".json": parse_json,
               ".xml": parse_xml, ".xlsx": parse_excel}
    parser = parsers.get(ext)
    if not parser:
        raise ValueError(f"Unsupported format: {ext}")
    return parser(file_path)

def parse_csv(path):
    with open(path) as f:
        sample = f.read(1024)
        f.seek(0)
        dialect = csv.Sniffer().sniff(sample)  # Auto-detect delimiter
        return list(csv.DictReader(f, dialect=dialect))

def parse_fixed_width(path):
    # Legacy: emp_id(0:10), name(10:40), dept(40:60)
    cols = [("emp_id",0,10), ("name",10,40), ("dept",40,60)]
    records = []
    with open(path) as f:
        for line in f:
            records.append({n: line[s:e].strip() for n,s,e in cols})
    return records
Real-world: Legacy government system sends fixed-width text. Modern SaaS sends XML. Your integration normalizes both into dicts before pushing to PeopleStrong API.
Follow-up: How to handle pipe-delimited? — csv.reader with delimiter='|'. csv.Sniffer() auto-detects this.
Q8. What if file is empty?
Scenario

Empty file could mean: no changes (valid), source error (invalid), or generation failure. Check file size first. If 0 bytes: log and skip. If header only (no data rows): "valid empty" — log "0 records." If the file should always have data (daily attendance), treat empty as error and alert. Config: allow_empty: true/false per integration.

Real-world: Source crashed during file generation. A 0-byte file was uploaded. Without the check, your integration silently succeeds with "0 records processed" and nobody notices today's data is missing.
Follow-up: How to differentiate "no changes" from "system failure"? — Check source system's health endpoint. Compare with historical pattern (if file always has 5000+ records and today has 0, that's suspicious).
Q9. How do you handle encoding issues (UTF-8, Latin-1)?
Code

Strategy: (1) Try UTF-8 first. (2) If UnicodeDecodeError, try UTF-8-SIG (BOM), Latin-1, CP1252 (Windows). (3) Use chardet library to auto-detect. (4) Document expected encoding in spec. (5) Always write output in UTF-8.

import chardet

def read_with_fallback(file_path):
    encodings = ["utf-8", "utf-8-sig", "latin-1", "cp1252"]
    for enc in encodings:
        try:
            with open(file_path, "r", encoding=enc) as f:
                records = list(csv.DictReader(f))
                print(f"Read with {enc} encoding")
                return records
        except UnicodeDecodeError:
            continue
    raise ValueError("Could not decode file")
Real-world: Indian names with diacritics. Source generates in Windows-1252. Your UTF-8 parser sees garbage. With chardet, it detects CP1252 and reads correctly.
Follow-up: Why does Latin-1 never throw UnicodeDecodeError? — It maps every byte (0-255) to a character. Always "succeeds" but may produce wrong characters. That's why it's the last fallback.
Q10. How do you implement file watcher (detect new files)?
Code

For SFTP, polling is the only option (no push notifications). Check directory every N minutes. Use a "last seen" state file to track processed files. Compare current file list against state to find new ones.

def poll_sftp_for_new_files(sftp, remote_dir, state_file="sftp_state.json"):
    seen = set(json.loads(Path(state_file).read_text())) if Path(state_file).exists() else set()
    current_files = set(sftp.listdir(remote_dir))
    new_files = current_files - seen
    if new_files:
        print(f"Found {len(new_files)} new files")
    seen.update(new_files)
    Path(state_file).write_text(json.dumps(list(seen)))
    return sorted(new_files)
Real-world: Vendor drops files at unpredictable times. Watcher polls every 5 minutes, detects new files, processes them. State file prevents reprocessing after restart.
Follow-up: What if a file is being uploaded while you scan? — Check file stability: wait 30s, check size again. If unchanged, safe to process. Or look for a trigger/done file.
Q11. What is the staging table pattern?
ScenarioCode

A staging table is a temporary DB table where you load raw file data before processing. Benefits: decouple parsing from business logic, validate with SQL, handle duplicates with GROUP BY, easy rollback (clear staging table). Pattern: Clear staging → Load raw data → Validate → Transform → Upsert into production → Archive staging.

-- Staging table (mirrors CSV structure)
-- stg_employees: row_num, emp_id, name, email, department,
--                load_date, is_valid, error_message

-- Step 1: Bulk load raw CSV data into staging
-- Step 2: Validate with SQL
--   Mark invalid: SET is_valid=FALSE WHERE emp_id IS NULL
--   Mark invalid: SET is_valid=FALSE WHERE email NOT LIKE '%@%'
-- Step 3: Upsert valid records to production table
--   Upsert from staging WHERE is_valid = TRUE
--   ON CONFLICT (emp_id) update name, email, department
Real-world: 10,000-row CSV with 50 invalid records. Without staging, you insert row by row (slow). With staging: bulk load, validate with fast SQL, upsert in one operation. If 50% bad data, reject entire load without touching production.
Follow-up: Why not insert directly into production? — Invalid data corrupts production. Staging isolates raw data. Validate before promoting. Bad batch? Clear staging, no harm done.
Q12. How do you handle partial file uploads (incomplete file)?
Scenario

Detection methods: (1) Control file — source uploads data.csv, then data.ctl with expected row count. Only process when both exist. (2) Trigger file — source uploads data.csv, then data.done (empty). Process only when .done exists. (3) File stability — check size at t=0 and t=30s; if same, complete. (4) Footer/trailer — last row contains expected count. Validate actual vs expected.

Interview tip: Control files and trigger files are extremely common in enterprise integration. Knowing these patterns shows real-world experience. Always mention the "atomic rename" pattern (upload as .tmp, rename to .csv) as the source-side solution.
Follow-up: What if trigger file arrives but data file is corrupt? — Check trigger exists, then validate data file (row count, checksum). If fails, move both to /error/.
Q13. How do you handle header mismatches?
Code

Header mismatches: columns renamed, extra columns, missing columns, reordered. Handle: (1) Compare actual header against expected. (2) Missing required columns: reject file. (3) Extra columns: ignore. (4) Renamed columns: use alias mapping. (5) Reordered: DictReader handles this (reads by name, not position).

def validate_headers(file_path, required, aliases=None):
    aliases = aliases or {}
    with open(file_path) as f:
        actual = set(csv.DictReader(f).fieldnames)
    normalized = {aliases.get(c, c) for c in actual}
    missing = set(required) - normalized
    if missing:
        raise ValueError(f"Missing required columns: {missing}")
    return True

validate_headers("employees.csv",
    required=["emp_id", "name", "email"],
    aliases={"employee_code": "emp_id", "full_name": "name"})
Real-world: Vendor renames "emp_code" to "employee_code." Without alias mapping, parser fails with KeyError. With aliases, silently maps to correct field.
Follow-up: Case sensitivity? — Normalize: fieldnames = [f.strip().lower() for f in reader.fieldnames]
Q14. How do you validate data types from CSV?
Code

CSV is all strings. You must validate and convert: dates, numbers, booleans, emails. Use Pydantic for schema-based validation. Handle null/empty explicitly. Log errors with row number and column name.

from pydantic import BaseModel, field_validator
from datetime import date

class EmployeeRecord(BaseModel):
    emp_id: str
    name: str
    salary: float
    join_date: date

    @field_validator("salary", mode="before")
    def parse_salary(cls, v):
        if isinstance(v, str):
            return float(v.replace(",", ""))  # "50,000" -> 50000.0
        return v

    @field_validator("join_date", mode="before")
    def parse_date(cls, v):
        for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%d-%b-%Y"):
            try: return datetime.strptime(v, fmt).date()
            except: continue
        raise ValueError(f"Cannot parse date: {v}")
Real-world: Salary as "50,000" (string with comma). Direct float() fails. Dates mixed: "2024-01-15" and "15/01/2024" in same file. Pydantic validators handle both.
Follow-up: Boolean fields? — CSV may have "true", "yes", "Y", "1" all meaning True. Create a parser handling all variants.
Q15. How to handle very large files (100MB+)?
CodeScenario

For 100MB+ files: (1) Never load into memory. Stream line by line. (2) Batch API calls — accumulate 500 records, POST, clear, repeat. (3) Track progress every 10K rows. (4) Use generators for memory efficiency. (5) Monitor rows/sec for performance baseline.

def stream_large_csv_to_api(file_path, api_url, headers, batch_size=500):
    stats = {"total": 0, "success": 0, "failed": 0}
    batch = []
    with open(file_path) as f:
        for row in csv.DictReader(f):
            batch.append(row)
            stats["total"] += 1
            if len(batch) >= batch_size:
                try:
                    requests.post(api_url, json={"records": batch}, headers=headers)
                    stats["success"] += len(batch)
                except:
                    stats["failed"] += len(batch)
                batch = []
                if stats["total"] % 10000 == 0:
                    print(f"Progress: {stats['total']} rows")
    if batch:  # remaining
        try:
            requests.post(api_url, json={"records": batch}, headers=headers)
            stats["success"] += len(batch)
        except:
            stats["failed"] += len(batch)
    return stats
Real-world: Monthly attendance: 500K rows, 150MB CSV. Loading all = 1.5GB RAM. Streaming with 500-record batches uses ~1MB RAM. At 500 rows/sec, completes in ~17 minutes.
Follow-up: 6-hour file? — Checkpoint every 10K rows. If interrupted, resume from checkpoint. Consider parallel: split file, process chunks concurrently.

4 Full Load vs Incremental Load

Q1. What is full load? When to use?
Scenario

Full load means extracting ALL records from the source and replacing ALL records in the target. It's the simplest approach: clear target, insert everything. Use when: (1) Source system has no way to track changes (no modified_date). (2) Data volume is small (under 10K records). (3) Initial load (first-time setup). (4) After a major data migration or cleanup. (5) Weekly/monthly reconciliation runs. Downside: slow for large datasets, resource-intensive, and causes downtime if you clear-and-reload.

Real-world: A company list of 200 departments rarely changes. Full load daily takes 5 seconds. The simplicity of "delete all, insert all" is worth it vs building complex change tracking for 200 records.
Follow-up: What about downtime during full load? — Use the "swap table" pattern: load into a staging table, then atomically rename staging to production (zero downtime).
Q2. What is incremental load? When to use?
Scenario

Incremental load means extracting only records that changed since the last sync (new, modified, deleted). Much faster and less resource-intensive than full load. Use when: (1) Large data volume (100K+ records). (2) Source has a reliable modified_date or change tracking. (3) Frequent syncs (hourly/real-time). (4) Network bandwidth is limited. (5) Target system can handle upserts. Methods: timestamp-based, CDC (Change Data Capture), event-driven.

Real-world: 500K employees, but only 50 change per day. Full load: 30 min to transfer 500K. Incremental: 5 seconds to transfer 50 changed records. That's 360x faster.
Follow-up: What if 50% of records change daily? — At that point, full load might be simpler and faster. The break-even is typically around 10-20% change rate.
Q3. What is the difference between full and incremental?
Tip

Key differences: Full: Extract all records, replace target completely. Simple but slow. Good for small datasets or reconciliation. Incremental: Extract only changes, upsert to target. Fast but complex. Requires change tracking. Risk: can miss records if change tracking is unreliable. Best practice: use incremental for daily operations, full load weekly for reconciliation. This hybrid approach catches anything incremental missed.

Interview tip: Present this as a table comparing: data volume transferred, complexity, speed, risk, and use case. Show you understand trade-offs, not just definitions.
Follow-up: Can you use both together? — Yes! Incremental daily, full load weekly. The weekly full load catches any records that incremental missed (clock skew, bug in change tracking, etc.).
Q4. How do you implement incremental using timestamp?
CodeScenario

Timestamp-based incremental: (1) Store the last sync timestamp (high watermark). (2) Query source for records WHERE modified_date > last_sync_time. (3) Upsert those records into target. (4) Update the watermark to the MAX(modified_date) from this batch. Critical: use the data's max timestamp, not the current clock time, to avoid missing records modified during the sync window.

def incremental_sync(source_url, target_url, headers, watermark_file="watermark.json"):
    # Load watermark
    if Path(watermark_file).exists():
        watermark = json.loads(Path(watermark_file).read_text())["last_sync"]
    else:
        watermark = "1970-01-01T00:00:00Z"  # First run = get everything

    # Fetch only changed records
    resp = requests.get(source_url, params={"modified_since": watermark}, headers=headers)
    records = resp.json()["data"]

    if not records:
        print("No changes since last sync")
        return

    # Upsert to target
    result = batch_upload(records, target_url, headers)

    # Update watermark to MAX modified_date from the data
    max_modified = max(r["modified_date"] for r in records)
    Path(watermark_file).write_text(json.dumps({"last_sync": max_modified}))
    print(f"Synced {len(records)} changed records. New watermark: {max_modified}")
Real-world: Last sync at 2024-01-14T23:00:00Z. Since then, 47 employees updated their profiles. Query returns only those 47 instead of all 500K. Sync takes 2 seconds instead of 30 minutes.
Follow-up: What if clocks are out of sync? — Use the source system's timestamp, not your local clock. If source and target have clock skew, add a buffer: query modified_since = watermark - 5 minutes (overlap is better than missing records).
Q5. How do you implement incremental using CDC (Change Data Capture)?
ScenarioCode

CDC tracks every change at the database level. Types: (1) Log-based CDC — reads database transaction log (WAL in Postgres, binlog in MySQL). Most accurate, no source modification needed. Tools: Debezium, AWS DMS. (2) Trigger-based — DB triggers write changes to a changelog table. (3) Polling-based — periodically query for changes using timestamps (simplest CDC). Log-based is the gold standard because it captures ALL changes including deletes without modifying the source.

# Polling-based CDC (simplest form)
# Requires a changes table in source DB:
# change_log: id, table_name, record_id, operation (I/U/D),
#             changed_data, changed_at

def cdc_sync(source_db, target_url, headers, last_change_id=0):
    # Fetch changes since last processed ID
    changes = source_db.execute(
        "SELECT * FROM change_log WHERE id > ? ORDER BY id",
        (last_change_id,)
    ).fetchall()

    for change in changes:
        if change["operation"] == "I":  # Insert
            requests.post(target_url, json=change["changed_data"], headers=headers)
        elif change["operation"] == "U":  # Update
            requests.put(f"{target_url}/{change['record_id']}",
                        json=change["changed_data"], headers=headers)
        elif change["operation"] == "D":  # Delete (soft)
            requests.patch(f"{target_url}/{change['record_id']}",
                         json={"status": "deleted"}, headers=headers)

    if changes:
        new_last_id = changes[-1]["id"]
        save_checkpoint(new_last_id)
Real-world: Debezium reads PostgreSQL WAL, captures every INSERT/UPDATE/DELETE on the employees table, and publishes events to Kafka. Your consumer reads Kafka events and pushes to the target API in real-time. Latency: under 1 second from change to sync.
Follow-up: Debezium vs custom CDC? — Debezium: production-ready, handles schema changes, exactly-once delivery. Custom: simpler for small scale but lacks these features. Use Debezium for serious integration.
Q6. What if source doesn't have a modified_date column?
ScenarioTip

This is a common real-world problem. Solutions: (1) Hash comparison — compute a hash of each record, compare with stored hashes. Changed hash = changed record. (2) Full snapshot comparison — extract full dataset, compare with previous snapshot, find differences. (3) Ask source to add modified_date — the right long-term solution. (4) DB trigger approach — add a trigger on the source table that updates a timestamp column on every change. (5) Log-based CDC — doesn't need modified_date, reads from DB transaction logs.

import hashlib

def detect_changes_by_hash(current_records, previous_hashes, key_field="emp_id"):
    changes = {"new": [], "modified": [], "deleted": []}
    current_hashes = {}

    for record in current_records:
        key = record[key_field]
        record_hash = hashlib.md5(json.dumps(record, sort_keys=True).encode()).hexdigest()
        current_hashes[key] = record_hash

        if key not in previous_hashes:
            changes["new"].append(record)
        elif previous_hashes[key] != record_hash:
            changes["modified"].append(record)

    # Detect deletions
    deleted_keys = set(previous_hashes.keys()) - set(current_hashes.keys())
    changes["deleted"] = list(deleted_keys)

    return changes, current_hashes
Real-world: Legacy system from 2005 has no modified_date. You extract all 10K records, hash each one, compare with yesterday's hashes. Only 30 hashes changed — those 30 are your delta. Stored hashes: ~500KB.
Follow-up: Isn't this just a full load with extra steps? — Yes, you extract everything, but you only PUSH the delta. The extraction is full, but the load is incremental. Saves target API calls and prevents unnecessary updates.
Q7. How do you handle deletes in incremental load?
ScenarioTip

Deletes are the hardest part of incremental sync. Approaches: (1) Soft delete flag — source sets is_deleted=true, your incremental picks it up via modified_date. Best approach. (2) Tombstone events — CDC publishes a DELETE event. (3) Full snapshot diff — compare current full extract with previous; missing records = deleted. (4) Periodic reconciliation — weekly full load identifies records in target but not in source. (5) Never delete — some systems just mark inactive, never hard delete.

Interview tip: This is a tricky question that catches many candidates. Most people think of INSERT and UPDATE but forget DELETE. Saying "soft delete with modified_date" is the best answer. If the source does hard deletes, mention that you need CDC or reconciliation to detect them.
Follow-up: What if you need to propagate hard deletes? — You can't detect a hard delete from timestamp alone. You need either CDC (captures DELETE events) or periodic full extract to compare and find missing records.
Q8. What is a watermark? How do you maintain it?
Code

A watermark (or high-water mark) is the marker that tracks your position in the data stream. For timestamp-based: it's the MAX(modified_date) from the last successful sync. For ID-based: it's the last processed ID. For CDC: it's the log sequence number (LSN). Rules: (1) Only advance watermark after SUCCESSFUL processing. (2) Store persistently (file, DB, not memory). (3) Use atomic write (temp file + rename) to prevent corruption. (4) Include metadata (sync run ID, record count) for debugging.

class WatermarkManager:
    def __init__(self, path="watermark.json"):
        self.path = Path(path)
        self.data = self._load()

    def _load(self):
        if self.path.exists():
            return json.loads(self.path.read_text())
        return {"value": None, "updated_at": None, "run_id": None}

    def get(self):
        return self.data["value"]

    def advance(self, new_value, run_id, record_count):
        # Atomic write: write to temp, then rename
        self.data = {
            "value": new_value,
            "updated_at": datetime.utcnow().isoformat(),
            "run_id": run_id,
            "records_processed": record_count,
            "previous_value": self.data.get("value"),
        }
        tmp = self.path.with_suffix(".tmp")
        tmp.write_text(json.dumps(self.data, indent=2))
        tmp.rename(self.path)
Real-world: Sync fails after processing 1000 records (target API down). Watermark is still at the old value because you only advance on success. Next run re-fetches and retries those 1000 records. No data loss.
Follow-up: What if you advance the watermark but the target didn't actually save? — That's a "lost update" bug. Solution: only advance watermark after confirming target accepted the data (check response status). Or use two-phase commit for critical data.
Q9. Full load takes 6 hours but window is 2 hours — what do you do?
ScenarioTip

Optimization strategies: (1) Switch to incremental — the best solution if possible. (2) Parallel extraction — split data into ranges (A-M, N-Z) and fetch in parallel. (3) Compression — gzip API responses. (4) Reduce payload — request only needed fields. (5) Bulk insert at target (COPY instead of INSERT). (6) Partition the load — full load different segments on different days (departments on Monday, employees on Tuesday). (7) Expand the window — negotiate with stakeholders for more time.

Interview tip: This tests your problem-solving, not just knowledge. Walk through options systematically: first try to reduce data volume (incremental), then speed up transfer (parallel, compress), then speed up loading (bulk insert), and finally negotiate (expand window). Show you think about trade-offs.
Follow-up: What if you can't switch to incremental? — Use "partitioned full load": departments today, locations tomorrow, employees Wednesday. Each segment completes within 2 hours. Full data refreshed weekly.
Q10. How do you handle initial load vs ongoing incremental?
Code

Initial load is the first-time full sync to bootstrap the target. After that, switch to incremental. Implementation: (1) Check if watermark exists. No watermark = initial load (fetch everything). (2) Initial load: full extract, bulk insert, set watermark. (3) Subsequent runs: check watermark, fetch delta, upsert. (4) Initial load might need special handling (larger batch sizes, progress tracking, chunked processing for memory).

def smart_sync(source_url, target_url, headers, watermark_mgr):
    watermark = watermark_mgr.get()

    if watermark is None:
        print("Initial load - fetching ALL records")
        records = fetch_all_offset(source_url, headers)
        print(f"Initial load: {len(records)} records")
    else:
        print(f"Incremental load since {watermark}")
        resp = requests.get(source_url,
            params={"modified_since": watermark}, headers=headers)
        records = resp.json()["data"]
        print(f"Incremental: {len(records)} changed records")

    if records:
        result = batch_upload(records, target_url, headers)
        max_ts = max(r["modified_date"] for r in records)
        watermark_mgr.advance(max_ts, run_id="sync_001", record_count=len(records))
Real-world: New PeopleStrong customer goes live. Day 1: initial load of 50K employees (takes 1 hour). Day 2 onwards: incremental sync of ~100 changes per day (takes 5 seconds). The watermark file's existence is the switch between modes.
Follow-up: What if initial load fails halfway? — Use chunked processing with checkpoints. If it fails at record 25,000, restart from 25,000 (not from 0). Don't set watermark until complete.
Q11. What is merge/upsert? How do you implement it?
CodeScenario

Upsert = INSERT if new, UPDATE if exists. Essential for incremental loads because you don't know if a record is new or modified. Implementation: (1) SQL: INSERT ... ON CONFLICT DO UPDATE (Postgres), MERGE (SQL Server, Oracle). (2) API: PUT (idempotent replace) or PATCH (partial update). (3) Application-level: check if exists, then insert or update. Always prefer database-level upsert — it's atomic and handles race conditions.

-- PostgreSQL Upsert
-- Insert new employees, update existing ones by emp_id
-- ON CONFLICT (emp_id) DO UPDATE SET
--   name = EXCLUDED.name, email = EXCLUDED.email,
--   department = EXCLUDED.department, updated_at = NOW()

# Python application-level upsert
def upsert_to_api(record, api_url, headers):
    emp_id = record["emp_id"]
    # Check if exists
    check = requests.get(f"{api_url}/{emp_id}", headers=headers)
    if check.status_code == 200:
        # Update existing
        requests.put(f"{api_url}/{emp_id}", json=record, headers=headers)
    elif check.status_code == 404:
        # Insert new
        requests.post(api_url, json=record, headers=headers)
    else:
        check.raise_for_status()
Real-world: Incremental sync has 100 records: 80 existing employees who updated their email, 20 new hires. Upsert handles both without you needing to separate them. Without upsert, you'd need two API calls (check then insert/update) per record.
Follow-up: GET-then-PUT upsert has a race condition — what if another process inserts between your GET and POST? Use the DB-level ON CONFLICT or API-level PUT (which is inherently idempotent).
Q12. How do you handle late-arriving data?
Scenario

Late-arriving data has a modified_date BEFORE your watermark (e.g., record modified at 10 PM yesterday but not committed to DB until 2 AM today, after your midnight sync). Solutions: (1) Overlap window — query with watermark minus a buffer (e.g., 2 hours). Accept some duplicates (upsert handles them). (2) Monotonic ID — use auto-increment ID instead of timestamp. (3) CDC — log-based CDC captures the actual commit time, not the business timestamp. (4) Reconciliation — weekly full sync catches anything missed.

Interview tip: This is a classic "tricky" question. The overlap window approach is the most practical answer. Say: "I'd subtract a safety buffer from the watermark, e.g., 2 hours. Yes, this means some records are fetched twice, but with upsert/idempotency, duplicates are harmless. Missing records is much worse than processing duplicates."
Follow-up: How big should the overlap window be? — Depends on the source system's latency. If commits are usually within 30 minutes, use 1-hour buffer. Monitor: if reconciliation keeps finding missed records, increase the buffer.
Q13. What is SCD Type 1 vs Type 2?
ScenarioCode

SCD Type 1: Overwrite old value with new. No history preserved. Simple. Use when history doesn't matter (e.g., correcting a typo in name). SCD Type 2: Keep history. When a value changes, close the old record (set end_date) and insert a new record with the new value. Enables time-travel queries ("What was this employee's department on Jan 1?"). Use for: department changes, designation changes, salary history.

# SCD Type 1: Simple overwrite
def scd_type1(record, db):
    db.execute("UPDATE employees SET name=?, dept=? WHERE emp_id=?",
               (record["name"], record["dept"], record["emp_id"]))

# SCD Type 2: Preserve history
def scd_type2(record, db):
    # Close current record
    db.execute(
        "UPDATE employee_history SET is_current=FALSE, end_date=? WHERE emp_id=? AND is_current=TRUE",
        (datetime.now(), record["emp_id"]))
    # Insert new version
    db.execute(
        "INSERT INTO employee_history (emp_id, name, dept, start_date, is_current) VALUES (?,?,?,?,TRUE)",
        (record["emp_id"], record["name"], record["dept"], datetime.now()))

# Query: "What department was EMP001 in on Jan 1, 2024?"
# SELECT dept FROM employee_history
# WHERE emp_id='EMP001' AND start_date <= '2024-01-01' AND (end_date > '2024-01-01' OR is_current=TRUE)
Real-world: Employee moves from Engineering to Marketing on March 1. SCD Type 1: record shows Marketing, history lost. SCD Type 2: two rows — Engineering (Jan 1 - Feb 28) and Marketing (Mar 1 - current). HR can answer "who was in Engineering in February?"
Follow-up: SCD Type 3? — Add previous_value columns (current_dept, previous_dept). Only stores one level of history. Rarely used in practice — Type 2 is more flexible.
Q14. How do you detect and handle schema drift?
Code

Schema drift = source schema changes without notice (new columns, renamed columns, type changes). Detection: (1) Compare current response schema with stored expected schema before processing. (2) Alert on any changes. (3) Auto-handle additive changes (new optional columns). (4) Fail on breaking changes (removed required columns, type changes). (5) Maintain a schema registry that versioned schemas.

def detect_schema_drift(current_record, expected_schema):
    current_fields = set(current_record.keys())
    expected_fields = set(expected_schema.keys())

    drift = {
        "new_fields": current_fields - expected_fields,
        "removed_fields": expected_fields - current_fields,
        "type_changes": [],
    }

    for field in current_fields & expected_fields:
        current_type = type(current_record[field]).__name__
        expected_type = expected_schema[field]
        if current_type != expected_type:
            drift["type_changes"].append({
                "field": field, "expected": expected_type, "actual": current_type})

    has_drift = any(drift.values())
    if has_drift:
        print(f"SCHEMA DRIFT DETECTED: {drift}")
        if drift["removed_fields"] or drift["type_changes"]:
            raise Exception("Breaking schema change detected!")
    return drift
Real-world: Source API v2.1 adds an "office_location" field. Your integration keeps working (additive change, ignored). Source v3.0 renames "emp_code" to "employee_id." Your integration breaks. Schema drift detection catches this immediately.
Follow-up: How do you handle it gracefully? — For additive changes: auto-accept. For breaking changes: switch to a compatibility mode (check both old and new field names) and alert the team to update the mapping config.
Q15. What is backfilling? When do you need it?
Scenario

Backfilling is re-processing historical data that was missed or incorrectly processed. Scenarios: (1) Bug in transformation logic — last 30 days of data had wrong department mapping. Need to re-extract and re-load. (2) New field added — need to populate it for existing records. (3) Incremental sync missed records due to clock skew. (4) New target system needs all historical data. Process: set watermark to the desired start date and re-run, or use a special "backfill mode" that processes a date range.

def backfill(source_url, target_url, headers, start_date, end_date):
    print(f"Backfilling from {start_date} to {end_date}")
    records = requests.get(source_url, params={
        "modified_since": start_date,
        "modified_before": end_date
    }, headers=headers).json()["data"]

    print(f"Backfill: {len(records)} records to reprocess")
    result = batch_upload(records, target_url, headers)
    print(f"Backfill complete: {result}")
    # Note: do NOT update watermark during backfill
    return result
Real-world: A bug in your mapping sent wrong department codes for 2 weeks. You fix the bug, then backfill: re-extract those 2 weeks of data and re-load with correct mapping. Important: don't update the watermark during backfill — it should stay at the current position.
Follow-up: How do you backfill without affecting production? — Use a separate pipeline/job for backfill. Process during off-peak hours. Use upsert so it safely overwrites existing records.
Q16. How do you validate data completeness after incremental load?
Code

Completeness checks: (1) Count validation — records fetched from source = records loaded to target. (2) Watermark continuity — no gaps between previous watermark and current. (3) Periodic full reconciliation — compare total counts weekly. (4) Trend analysis — if daily delta is usually 50-100 records and today it's 5, investigate. (5) Missing records check — spot-check specific known changes.

def validate_completeness(source_count, target_loaded, daily_avg, tolerance=0.2):
    report = {
        "source_records": source_count,
        "target_loaded": target_loaded,
        "match": source_count == target_loaded,
        "daily_avg": daily_avg,
    }

    # Anomaly detection: is today's count suspiciously low?
    if daily_avg > 0 and source_count < daily_avg * (1 - tolerance):
        report["warning"] = f"Count ({source_count}) is {tolerance:.0%} below average ({daily_avg})"

    if not report["match"]:
        report["error"] = f"Count mismatch: {source_count} source vs {target_loaded} target"

    return report
Real-world: Daily incremental usually syncs ~80 records. Today: only 3. Anomaly detection flags it. Investigation reveals: source API had a filter bug that excluded most changes. Without this check, 77 changes would be silently missed.
Follow-up: How do you build the daily_avg baseline? — Track daily counts for 30 days. Use rolling average. Alert when current < avg * 0.5 (50% below normal) or current > avg * 3 (300% above normal).
Q17. What if incremental load misses records?
Scenario

Records get missed due to: (1) Clock skew between source DB nodes. (2) Long-running transactions that commit after your watermark advances. (3) Soft delete without updating modified_date. (4) Source API bug filtering records. Detection: weekly full reconciliation comparing source and target counts and IDs. Fix: (1) Add overlap buffer to watermark query. (2) Switch to CDC for guaranteed capture. (3) Implement reconciliation alerts.

Interview tip: Acknowledge that incremental sync can miss records — this shows maturity. Then explain your defense: overlap window (trades duplicates for completeness), reconciliation (catches misses), and CDC (eliminates the problem entirely).
Follow-up: How do you find which specific records are missing? — Extract all IDs from both systems. Set difference: source_ids - target_ids = missing records. Re-fetch those specific records by ID and load them.
Q18. How do you handle timezone issues in incremental load?
ScenarioCode

Timezone issues cause missed records or duplicate processing. Rules: (1) Store watermark in UTC. (2) Convert source timestamps to UTC before comparison. (3) If source doesn't include timezone info, DOCUMENT which timezone they assume. (4) Be aware of DST (Daylight Saving Time) — clocks jump forward/backward, creating gaps or overlaps. (5) Always use timezone-aware datetime objects, never naive.

from zoneinfo import ZoneInfo

def safe_incremental_query(source_url, watermark_utc, source_tz="Asia/Kolkata"):
    # Convert UTC watermark to source's timezone for the API call
    utc_dt = datetime.fromisoformat(watermark_utc).replace(tzinfo=ZoneInfo("UTC"))
    source_dt = utc_dt.astimezone(ZoneInfo(source_tz))

    resp = requests.get(source_url, params={
        "modified_since": source_dt.isoformat()
    })
    records = resp.json()["data"]

    # Convert response timestamps back to UTC for watermark
    for r in records:
        local = datetime.fromisoformat(r["modified_date"])
        if local.tzinfo is None:
            local = local.replace(tzinfo=ZoneInfo(source_tz))
        r["modified_date"] = local.astimezone(ZoneInfo("UTC")).isoformat()

    return records
Real-world: PeopleStrong API returns IST timestamps. Your watermark is in UTC. Without conversion, you query for records after "2024-01-15T05:00:00Z" but the API interprets it as IST (9:30 AM ahead), missing 5.5 hours of data.
Follow-up: What about DST transitions? — India doesn't have DST, but US/EU do. During "spring forward," 2:00 AM to 3:00 AM doesn't exist. Records with timestamps in that gap may cause parsing errors. Always use UTC to avoid this entirely.
Q19. What is exactly-once vs at-least-once delivery?
Scenario

At-most-once: Fire and forget. May lose records. Never use for integration. At-least-once: Retry on failure. May process duplicates. The standard for most integrations (combined with idempotency). Exactly-once: Each record processed exactly once. Theoretically impossible in distributed systems (Two Generals' Problem). Approximated via: idempotent operations + at-least-once delivery + deduplication. In practice, design for at-least-once and make your operations idempotent.

Interview tip: Saying "exactly-once is impossible in distributed systems" shows deep understanding. Then say "we approximate it with idempotent operations and at-least-once delivery." This is the correct, senior-level answer.
Follow-up: How does Kafka achieve exactly-once? — Via idempotent producers (dedup by sequence number) + transactional writes (atomic read-process-write). It's "effectively exactly-once" within the Kafka ecosystem, but not across external systems.
Q20. How do you rollback a failed incremental load?
CodeScenario

Rollback strategies: (1) Don't advance watermark — simplest. If load fails, watermark stays at old value. Next run re-fetches and retries. (2) Soft delete loaded records — if partial load corrupted target, mark loaded records as invalid. (3) Restore from backup — for critical data, take a snapshot before loading. (4) Compensation events — in event-driven systems, publish "undo" events. (5) Staging table pattern — load into staging first, only promote to production on full success.

def safe_incremental_with_rollback(source_url, target_url, headers, watermark_mgr):
    old_watermark = watermark_mgr.get()
    records = fetch_changes(source_url, old_watermark, headers)

    # Track what we loaded (for potential rollback)
    loaded_ids = []

    try:
        for batch in chunk_list(records, 100):
            resp = requests.post(target_url, json={"records": batch}, headers=headers)
            resp.raise_for_status()
            loaded_ids.extend([r["emp_id"] for r in batch])

        # All successful - advance watermark
        max_ts = max(r["modified_date"] for r in records)
        watermark_mgr.advance(max_ts, "run_001", len(records))
    except Exception as e:
        print(f"LOAD FAILED: {e}. Rolling back {len(loaded_ids)} records")
        # Rollback: mark loaded records for re-processing
        for eid in loaded_ids:
            requests.patch(f"{target_url}/{eid}",
                json={"needs_resync": True}, headers=headers)
        # Watermark NOT advanced - next run will retry
        raise
Real-world: Batch 7 of 10 fails with 500 error. Batches 1-6 were loaded (600 records). Watermark stays at old value. Next run re-fetches all 1000 records. Upsert handles the 600 already-loaded records (no duplicates). Batch 7 retries and succeeds.
Follow-up: What if rollback itself fails? — Log the failure, the loaded record IDs, and alert. Manual intervention may be needed. This is why reconciliation is critical — it catches these edge cases.

5 ETL Pipeline Design Questions

Q1. Design an ETL pipeline for daily employee sync between two systems
ScenarioCode

A production-grade daily employee sync pipeline: (1) Schedule — cron/Airflow triggers at 2 AM. (2) Extract — incremental fetch from source API using watermark. (3) Validate — schema validation, data type checks, required field checks. (4) Transform — field mapping, data cleaning, enrichment (lookup department names from codes). (5) Load — batch upsert to target with retry logic. (6) Reconcile — count check, sample verification. (7) Notify — email/Slack with results. (8) Archive — store raw extract for audit. Each step is idempotent and can be restarted independently.

class EmployeeSyncPipeline:
    def __init__(self, config):
        self.config = config
        self.watermark = WatermarkManager(config["watermark_path"])
        self.stats = {"extracted": 0, "valid": 0, "loaded": 0, "errors": []}

    def run(self):
        run_id = f"sync_{datetime.now():%Y%m%d_%H%M%S}"
        print(f"[{run_id}] Starting employee sync")

        try:
            # Step 1: Extract
            raw_records = self.extract()
            self.stats["extracted"] = len(raw_records)

            if not raw_records:
                print("No changes detected")
                return self.stats

            # Step 2: Validate
            valid, invalid = self.validate(raw_records)
            self.stats["valid"] = len(valid)

            # Step 3: Transform
            transformed = self.transform(valid)

            # Step 4: Load
            result = self.load(transformed)
            self.stats["loaded"] = result["success"]

            # Step 5: Update watermark
            max_ts = max(r["modified_date"] for r in raw_records)
            self.watermark.advance(max_ts, run_id, len(valid))

            # Step 6: Reconcile & Notify
            self.reconcile()
            self.notify(run_id)

        except Exception as e:
            self.stats["errors"].append(str(e))
            self.notify_failure(run_id, e)
            raise

        return self.stats

    def extract(self):
        wm = self.watermark.get()
        params = {"modified_since": wm} if wm else {}
        return fetch_all_offset(self.config["source_url"],
            headers=self.config["source_headers"])

    def validate(self, records):
        valid, invalid = [], []
        for r in records:
            try:
                EmployeeSchema(**r)
                valid.append(r)
            except Exception as e:
                invalid.append({"record": r, "error": str(e)})
        return valid, invalid

    def transform(self, records):
        return [{
            "employee_id": r["emp_code"],
            "full_name": f"{r['first_name']} {r['last_name']}".strip(),
            "email": r["email"].lower().strip(),
            "department": r.get("dept_name", "Unknown"),
        } for r in records]

    def load(self, records):
        return batch_upload(records, self.config["target_url"],
            self.config["target_headers"])
Real-world: PeopleStrong (HRMS) to ADP (Payroll). Every night: extract new hires, terminations, and profile changes. Transform PeopleStrong codes to ADP codes. Load via ADP API. Email payroll team the results. This pipeline runs for 200+ customers.
Follow-up: How would you make this fault-tolerant? — Add checkpoint after each step. If step 4 (load) fails, restart from step 4 (not step 1). Use the staging pattern: extract to a local file, then process from file.
Q2. How do you handle errors in the middle of a pipeline?
ScenarioTip

Error handling strategy: (1) Isolate stages — each stage writes its output to disk/DB. If stage 3 fails, stage 2's output is preserved — restart from stage 3. (2) Partial failure — within a stage, continue processing valid records. Send failures to DLQ. (3) Checkpoint — after each batch, save progress. Resume from last checkpoint. (4) Alerting — immediate alert on failure with context (stage, record, error). (5) Retry policy — transient errors retry automatically; permanent errors go to DLQ. (6) Compensation — if stage 4 partially loaded before failing, mark those records for re-processing.

Interview tip: Mention that you design each stage to be idempotent and restartable. This means running a stage twice produces the same result. It's the key to reliable error recovery. Interviewers love hearing "checkpoint and resume" over "retry from the beginning."
Follow-up: What if the error is in the transform logic (bug)? — This is different from a transient error. Fix the bug, then reprocess the extract file (which you saved to disk). No need to re-extract from source.
Q3. How do you implement logging and monitoring?
Code

Logging levels: (1) INFO — pipeline started, records extracted/loaded, pipeline completed. (2) WARNING — retries, skipped records, anomalous counts. (3) ERROR — failures with full context. Every log entry includes: run_id, timestamp, stage, record count. Monitoring: track pipeline duration, success rate, record count trends. Alert on: failures, duration > 2x normal, count anomalies. Use structured logging (JSON) for searchability.

import logging, json

class PipelineLogger:
    def __init__(self, run_id):
        self.run_id = run_id
        self.logger = logging.getLogger("pipeline")
        self.metrics = {"start": datetime.utcnow().isoformat(), "stages": {}}

    def stage_start(self, stage_name):
        self.metrics["stages"][stage_name] = {"start": datetime.utcnow().isoformat()}
        self.logger.info(json.dumps({
            "run_id": self.run_id, "stage": stage_name, "event": "start"}))

    def stage_end(self, stage_name, record_count):
        self.metrics["stages"][stage_name]["end"] = datetime.utcnow().isoformat()
        self.metrics["stages"][stage_name]["records"] = record_count
        self.logger.info(json.dumps({
            "run_id": self.run_id, "stage": stage_name,
            "event": "complete", "records": record_count}))

    def error(self, stage_name, error, record=None):
        self.logger.error(json.dumps({
            "run_id": self.run_id, "stage": stage_name,
            "error": str(error), "record": record}))
Real-world: Production dashboard shows: pipeline ran at 2:01 AM, extracted 152 records (INFO), validated 150 (2 rejected, WARNING), loaded 150 to target (INFO), completed in 23 seconds (INFO). Monday morning: team sees green checkmarks. If red, they drill into ERROR logs for details.
Follow-up: What monitoring tools? — ELK/Grafana for logs, Datadog/CloudWatch for metrics, PagerDuty/OpsGenie for alerting. At minimum, email notifications on every run.
Q4. What is data reconciliation?
Scenario

Reconciliation is the process of comparing source and target data to verify they match. Types: (1) Count reconciliation — total records in source = total in target. (2) Value reconciliation — specific field values match (salary sums, employee details). (3) Key reconciliation — all primary keys in source exist in target. (4) Trend reconciliation — day-over-day counts are within expected range. Run automatically after every sync + weekly full reconciliation. Any mismatch triggers an alert and investigation.

Real-world: Friday afternoon: reconciliation report shows source has 5,232 employees, target has 5,230. Two missing. Investigation finds: two employees with emoji in their names (unicode issue) were silently rejected by target API. Fix deployed, records re-synced, Monday morning: 5,232 = 5,232.
Follow-up: How often should you run full reconciliation? — Weekly for most integrations. Daily for financial/payroll data. Monthly for low-change master data. After any system migration or major change: immediate full reconciliation.
Q5. How do you handle data quality issues?
Code

Data quality dimensions: (1) Completeness — no missing required fields. (2) Accuracy — values are correct (email format, date ranges). (3) Consistency — same employee shouldn't have different DOBs in two systems. (4) Timeliness — data is current, not stale. (5) Uniqueness — no duplicates. Implement: validation rules per field, quarantine for bad records, data quality dashboard, and trend tracking (quality score over time).

class DataQualityChecker:
    def __init__(self):
        self.rules = []
        self.results = []

    def add_rule(self, field, check_fn, error_msg):
        self.rules.append({"field": field, "check": check_fn, "msg": error_msg})

    def validate(self, record):
        errors = []
        for rule in self.rules:
            value = record.get(rule["field"])
            if not rule["check"](value):
                errors.append({"field": rule["field"], "value": value, "error": rule["msg"]})
        return errors

# Setup quality rules
dq = DataQualityChecker()
dq.add_rule("email", lambda v: v and "@" in v, "Invalid email format")
dq.add_rule("emp_id", lambda v: v and len(v) > 0, "emp_id is required")
dq.add_rule("salary", lambda v: v is None or (isinstance(v, (int,float)) and v >= 0), "Salary must be non-negative")
dq.add_rule("join_date", lambda v: v and v > "1950-01-01", "Join date seems invalid")
Real-world: Source system allows salary = -5000 (data entry error). Without DQ checks, negative salary flows to payroll and generates a deduction instead of a payment. With DQ rules, it's quarantined and flagged immediately.
Follow-up: Quarantine vs reject? — Quarantine: store the bad record for review and manual fix. Reject: discard completely. Quarantine is preferred because the data might be fixable without re-extracting from source.
Q6. What is a staging area and why use it?
Scenario

A staging area is an intermediate storage between source and target. Can be: (1) A staging DB table. (2) A local file (CSV/JSON). (3) A message queue (Kafka, SQS). Benefits: (1) Decoupling — extract and load run independently. If load fails, extract's data is preserved. (2) Validation — validate in staging before touching production. (3) Auditability — staging preserves what was extracted. (4) Retry — re-load from staging without re-extracting. (5) Performance — bulk operations on staging are faster.

Real-world: Extract at 2 AM pulls 10K records from source API. Saved to staging table. At 2:30 AM, load to target begins but target is down for maintenance. At 4 AM, target comes back. Load resumes from staging — no need to re-call the source API. Source might even have changed in those 2 hours.
Follow-up: File-based vs DB-based staging? — File (CSV): simplest, good for SFTP integrations, no DB needed. DB table: better for complex transforms (SQL), validation, and dedup. Use DB staging for API integrations, file staging for SFTP integrations.
Q7. How do you handle PII data in ETL?
ScenarioTip

PII handling rules: (1) Encrypt in transit — HTTPS for APIs, SFTP (not FTP) for files. (2) Encrypt at rest — encrypt staging files/tables. PGP for SFTP files. (3) Mask in logs — never log full email, phone, SSN, Aadhaar. (4) Access control — restrict who can see PII in staging. (5) Data minimization — only transfer PII that's needed. (6) Retention — delete staging data after processing. (7) Audit trail — log who accessed PII when. (8) Compliance — follow DPDP (India), GDPR (EU), SOC2.

def mask_for_logging(record):
    """Mask PII before writing to logs"""
    masked = record.copy()
    pii_fields = ["email", "phone", "aadhaar", "pan", "bank_account"]
    for field in pii_fields:
        if field in masked and masked[field]:
            masked[field] = f"{masked[field][:2]}***{masked[field][-2:]}"
    return masked

# In pipeline: always log masked version
logger.info(f"Processing record: {mask_for_logging(record)}")
# Log shows: {email: "sh***om", phone: "98***45", aadhaar: "12***89"}
Interview tip: PII handling is critical at PeopleStrong (HR data = lots of PII). Mentioning DPDP Act, encryption at rest AND in transit, and log masking shows you understand the regulatory landscape. Don't just say "we encrypt it" — be specific about WHERE and HOW.
Follow-up: What about developer access to production data? — Use anonymized/synthetic data for development. No real PII in dev/staging environments. Role-based access for production with audit logging.
Q8. What is idempotent pipeline design?
Scenario

An idempotent pipeline produces the same result whether you run it once or ten times with the same input. This is crucial for reliability because pipelines get retried (manual re-runs, cron overlap, retry after failure). Design rules: (1) Use upsert, not insert (prevents duplicates). (2) Use deterministic transformations (same input always produces same output). (3) Checkpointed watermark (re-running fetches same data). (4) No side effects outside the pipeline (don't send notifications in retry mode). (5) Each stage reads from a stable input (file/staging), not directly from a live API that may have changed.

Real-world: Cron fires twice at 2 AM (clock jitter). Without idempotency: 10K records inserted TWICE = 20K duplicates. With idempotent design: second run upserts the same 10K records — no effect. Data is correct regardless of how many times you run.
Follow-up: How do you test idempotency? — Simple test: run the pipeline, check state. Run it again immediately. State should be identical. Automate this as a regression test.
Q9. How do you handle dependencies between pipelines?
CodeScenario

Pipeline dependencies: department sync must complete before employee sync (employees reference departments). Solutions: (1) Airflow DAGs — define dependencies as a Directed Acyclic Graph. Task B runs only after Task A succeeds. (2) Status files — pipeline A writes a .done file; pipeline B waits for it. (3) Event-driven — pipeline A publishes "complete" event; pipeline B subscribes. (4) Sequential runner — a master script runs pipelines in order.

# Simple dependency manager
class PipelineOrchestrator:
    def __init__(self):
        self.pipelines = {}
        self.dependencies = {}

    def register(self, name, fn, depends_on=None):
        self.pipelines[name] = fn
        self.dependencies[name] = depends_on or []

    def run_all(self):
        completed = set()
        while len(completed) < len(self.pipelines):
            for name, fn in self.pipelines.items():
                if name in completed:
                    continue
                deps_met = all(d in completed for d in self.dependencies[name])
                if deps_met:
                    print(f"Running: {name}")
                    fn()
                    completed.add(name)
                    print(f"Completed: {name}")

# Usage
orch = PipelineOrchestrator()
orch.register("departments", sync_departments)
orch.register("designations", sync_designations)
orch.register("employees", sync_employees, depends_on=["departments", "designations"])
orch.register("attendance", sync_attendance, depends_on=["employees"])
orch.run_all()
# Order: departments -> designations -> employees -> attendance
Real-world: Four daily syncs: departments (2 AM), designations (2:05 AM), employees (2:10 AM), attendance (2:20 AM). If departments takes 15 minutes instead of 5, the orchestrator delays employee sync. Without orchestration, employee sync starts at 2:10 with incomplete department data.
Follow-up: What if a dependency fails? — All downstream pipelines are skipped (not just delayed). Alert: "departments failed, employees/attendance skipped." Fix departments, then re-trigger the chain.
Q10. How do you do performance tuning of ETL?
TipScenario

Optimization areas: (1) Extract — parallel page fetching, field filtering, compression, connection pooling. (2) Transform — vectorized operations (pandas/polars instead of row-by-row), lazy evaluation. (3) Load — bulk insert (not row-by-row), batch API calls, async I/O. (4) Infrastructure — more CPU/RAM, SSD storage, faster network. (5) Architecture — incremental over full load, partitioned processing, streaming over batch. Measure first: profile each stage to find the bottleneck. Don't optimize what's already fast.

# Performance monitoring wrapper
import time

def timed_stage(name):
    def decorator(fn):
        def wrapper(*args, **kwargs):
            start = time.time()
            result = fn(*args, **kwargs)
            elapsed = time.time() - start
            record_count = len(result) if hasattr(result, '__len__') else 'N/A'
            rate = len(result) / elapsed if elapsed > 0 and hasattr(result, '__len__') else 0
            print(f"[PERF] {name}: {elapsed:.2f}s | {record_count} records | {rate:.0f} rec/sec")
            return result
        return wrapper
    return decorator

@timed_stage("Extract")
def extract(): ...

@timed_stage("Transform")
def transform(records): ...

@timed_stage("Load")
def load(records): ...

# Output:
# [PERF] Extract: 12.34s | 5000 records | 405 rec/sec
# [PERF] Transform: 0.23s | 5000 records | 21739 rec/sec
# [PERF] Load: 45.67s | 5000 records | 109 rec/sec
# Bottleneck: Load stage (45s) -> optimize with parallel batch uploads
Real-world: Pipeline takes 45 minutes. Profiling shows: Extract 5 min, Transform 1 min, Load 39 min. The bottleneck is Load (sending records to API one by one). Fix: batch 100 records per API call. New time: Extract 5 min, Transform 1 min, Load 4 min = 10 min total. 4.5x improvement.
Follow-up: What tool do you use for profiling? — Python: cProfile for CPU, memory_profiler for RAM, time.time() for wall clock. For API calls: measure latency per call. For DB: query execution plans.

Built for PeopleStrong Integration Engineer Interview Prep

85+ Scenario-Based Questions | 50+ Code Examples | 5 Sections

All code examples are in Python. Concepts apply to any language.