SQL for Data Analysis Questions
Using SQL as a tool for data analysis and reporting. Focuses on writing queries to extract metrics, perform aggregations, join disparate data sources, use subqueries and window functions for trends and rankings, and prepare data for dashboards and reports. Includes best practices for reproducible analytical queries, handling time series and date arithmetic, basic query optimization considerations for analytic workloads, and when to use SQL versus built in reporting tools in analytics platforms.
HardSystem Design
57 practiced
Design an architecture to support a near real-time dashboard with 5-minute freshness using SQL-backed storage. Discuss streaming ingestion, small-window aggregations, incremental materialized views or pre-aggregations, handling late/deduplicated events, and the trade-offs between latency, consistency, and cost.
Sample Answer
Requirements & constraints:- Dashboard SLA: ~5-minute freshness, near real-time correctness- Source: high-throughput event stream (clicks, transactions)- Storage: SQL-backed (can be cloud OLAP like BigQuery, Redshift, Postgres/Cloud Spanner)- Needs small-window aggregations, dedupe, late-arrival handling, cost sensitivityHigh-level architecture:Event sources → Kafka (ingest, buffer, partition) → Stream processor (Flink / Apache Beam / ksqlDB) → SQL (OLAP / RDBMS) + read cache (Redis / materialized views) → Dashboard (PowerBI/Tableau)Streaming ingestion & processing:- Use Kafka for durable, partitioned ingestion and replay.- Stream processor consumes Kafka, performs: - Event validation & enrichment (user/product joins cached) - Deduplication via event-id state-store (keep seen keys for TTL) - Watermarking to bound lateness (e.g., 2–3 minutes allowed) - Small-window aggregations (tumblers/sliding windows like 1m/5m) using keyed stateSmall-window aggregations & incremental materialized views:- Compute incremental aggregates in the stream layer and upsert into SQL using atomic upserts (INSERT ... ON CONFLICT / MERGE) or write to append table + scheduled incremental SQL materialized view refresh.- For SQL-backed materialized views: maintain MV tables with last-processed offsets/timestamps. Stream job writes deltas and advances the offset checkpoint — this allows exactly-once/at-least-once semantics depending on connector.- Option: Use change-data-capture (CDC) + materialized view maintenance if primary data lives in SQL.Handling late and deduplicated events:- Use event-time processing with watermarks. Accept events until watermark + allowed lateness; beyond that, either: - Route to a corrections path where downstream accepts correction messages to update aggregates, or - Drop and log late events (if allowed).- Deduplication: maintain a bounded state of recent event IDs (TTL equals max-event-latency + safety). For high-cardinality keys, use a probabilistic structure (Bloom filter + periodic compaction) with risk of false positives.- Idempotent writes: use idempotency keys or upserts in SQL so retries don’t double-count.Consistency, latency, cost trade-offs:- Lower latency (freshness <1min): push more logic into stream processor and use low-latency OLTP/OLAP (e.g., Pinot, ClickHouse). Cost ↑ due to compute & storage.- Stronger consistency (exactly-once): requires transactional sinks (Flink two-phase commit) or idempotent upserts; increases complexity and cost.- Cheaper option: batch micro-batches (1–5 min) with simple ETL writing aggregated snapshots to SQL — simpler and cheaper but increases end-to-end latency and reduces ability to correct out-of-order events in real-time.- Materialized views in SQL reduce dashboard query cost but need careful MV invalidation/partial refresh strategies to avoid full-table recomputes.Operational concerns & metrics:- Monitor ingestion lag, watermark delay, state size, duplicate rate, and SQL sink latency.- Backfill & replay path from Kafka or object storage for re-compute.- Maintain SLA by autoscaling stream workers and using retention tiers: hot store for last 7 days, cold for long-term analytics.Why this fits a Data Scientist:- Enables near-real-time feature/metric access for modeling and dashboards- Stream processor does feature engineering and aggregates; SQL stores serve as canonical, queryable view for analyses and model validation- Balances freshness, cost, and correctness with configurable watermarks, TTLs, and materialized-view strategies.
HardTechnical
72 practiced
Explain causes of join cardinality misestimates that lead to poor query plans (examples: stale statistics, correlated columns, data skew). For each cause, propose SQL-level mitigations and DBA fixes (e.g., ANALYZE, extended statistics). Give a concrete example where rewriting a query results in a better plan.
Sample Answer
Common causes of join cardinality misestimates and mitigations:1) Stale or missing statistics- Cause: Planner uses outdated histograms / row counts so selectivity estimates are wrong.- SQL-level mitigation: RUN ANALYZE table; or for a single query, use SET LOCAL enable_seqscan = off; (temporary).- DBA fix: Schedule frequent ANALYZE/auto_analyze, increase sample_rate for ANALYZE, or use maintenance windows to refresh stats.Example:2) Correlated columns (multicolumn dependence)- Cause: Planner assumes independence between predicates (P(A and B)=P(A)*P(B)) leading to under/over-estimates.- SQL-level mitigation: Rewrite query to use a composite predicate or a subquery that preserves correlation; use explicit JOIN filters.- DBA fix: Create extended statistics (PostgreSQL) for column pairs/groups: CREATE STATISTICS.Example:3) Data skew / heavy-tailed distributions- Cause: A small subset of values dominates rows; planner assumes uniformity.- SQL-level mitigation: Add selective predicates earlier (filter pushdown), use explicit JOIN order via LATERAL or subqueries, or force planner with JOIN hints (if available).- DBA fix: Collect extended statistics, use BRIN indexes for clustered data, or store skewed values in separate summary tables.Example: Move highly selective filter into a subquery to reduce join size early.Concrete rewrite example (Postgres):Poor plan (estimator underestimates join size):Rewrite to apply selective predicate first (preserve correlation and reduce work):Reasoning: Filtering customers first reduces the build side of the join; the planner then chooses an index scan/hash join appropriately. Alternatively using a semi-join:This often yields fewer rows and a better plan.Key takeaways:- Keep statistics fresh and collect multicolumn stats for correlated columns.- Be aware of skew and rewrite queries to apply the most selective filters early (subqueries, EXISTS, LATERAL).- Monitor planner estimates (EXPLAIN ANALYZE), iterate: update stats, add extended stats, and refactor queries until estimates and actual costs align.
sql
ANALYZE orders;
ANALYZE customers;sql
CREATE STATISTICS st ON (col_a, col_b) FROM schema.table;
ANALYZE schema.table;sql
SELECT o.*
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE c.region = 'North' AND o.created_at >= '2025-01-01';sql
SELECT o.*
FROM (
SELECT id FROM customers WHERE region = 'North'
) c
JOIN orders o ON o.customer_id = c.id
WHERE o.created_at >= '2025-01-01';sql
SELECT o.*
FROM orders o
WHERE o.created_at >= '2025-01-01'
AND EXISTS (SELECT 1 FROM customers c WHERE c.id = o.customer_id AND c.region = 'North');HardTechnical
57 practiced
Implement first-touch, last-touch, and a simple time-decay attribution model in SQL. Tables:Write SQL that attributes conversion revenue to channels for each conversion under the three attribution rules and discuss assumptions and limitations of pure-SQL attribution.
touchpoints(user_id INT, channel TEXT, touch_time TIMESTAMP)
conversions(conversion_id INT, user_id INT, conversion_time TIMESTAMP, revenue NUMERIC)Sample Answer
Approach: for each conversion, join all prior touchpoints for the same user (touch_time <= conversion_time). Then:- First-touch: attribute full revenue to the earliest touch channel.- Last-touch: attribute full revenue to the latest touch channel.- Time-decay: give each touch a weight that decays with time-to-conversion, normalize weights per conversion, and split revenue proportionally.SQL (Postgres-compatible):Key points and choices:- We only consider touches occurring at or before conversion_time.- Time-decay uses exponential decay; lambda (0.1/hr) controls half-life. Change lambda to reflect business intuition (half-life = ln2 / lambda).- Normalization ensures per-conversion revenue sums to the actual conversion revenue.Complexity:- Joins scale O(T + C + matches). If users have many touches, the all_touches result can be large; performance depends on indexes on touchpoints(user_id, touch_time) and conversions(user_id, conversion_time).Edge cases and assumptions:- No prior touchpoints -> conversion un-attributed (excluded by WHERE); you may want to create a NULL-channel bucket.- Simultaneous touches: first/last tie-breaking follows ORDER BY (ASC/DESC) — may be arbitrary.- Cross-device identity: assumes user_id correctly links touches and conversions.- Attribution window: this uses unlimited lookback; in practice limit to N days to avoid noisy far-past touches.- Revenue precision: numeric sums preserved; consider currency rounding.Limitations of pure-SQL attribution:- Lacks causal inference — correlations, not causation.- Cannot easily control for confounders (e.g., user intent, organic lift) without richer data and modeling.- Time-decay parameter choice is heuristic; requires validation against experiments (e.g., holdout/A-B).- SQL can compute deterministic rules quickly, but advanced approaches (multi-touch attributions learned via Markov chains, Shapley, or uplift models) require statistical tooling and iterative validation outside pure SQL.Use this SQL for quick reporting and debugging; for strategic attribution, complement with experimental and model-based approaches.
sql
-- Parameters: choose decay rate (lambda per hour). Here lambda = 0.1
WITH all_touches AS (
SELECT
c.conversion_id,
c.user_id,
c.conversion_time,
c.revenue,
t.channel,
t.touch_time,
EXTRACT(EPOCH FROM (c.conversion_time - t.touch_time))/3600.0 AS hours_before -- hours between touch and conversion
FROM conversions c
JOIN touchpoints t
ON c.user_id = t.user_id
WHERE t.touch_time <= c.conversion_time
),
first_touch AS (
SELECT DISTINCT ON (conversion_id)
conversion_id, channel, revenue
FROM all_touches
ORDER BY conversion_id, touch_time ASC
),
last_touch AS (
SELECT DISTINCT ON (conversion_id)
conversion_id, channel, revenue
FROM all_touches
ORDER BY conversion_id, touch_time DESC
),
time_decay_weights AS (
SELECT
conversion_id,
channel,
revenue,
hours_before,
EXP(-0.1 * GREATEST(hours_before, 0)) AS raw_weight -- lambda=0.1 per hour
FROM all_touches
),
time_decay_norm AS (
SELECT
td.conversion_id,
td.channel,
td.revenue * td.raw_weight / SUM(td.raw_weight) OVER (PARTITION BY td.conversion_id) AS attributed_revenue
FROM time_decay_weights td
)
-- Results: attribution per conversion_id and channel for each model
SELECT 'first_touch' AS model, conversion_id, channel, SUM(revenue) AS attributed_revenue
FROM first_touch
GROUP BY conversion_id, channel
UNION ALL
SELECT 'last_touch' AS model, conversion_id, channel, SUM(revenue) AS attributed_revenue
FROM last_touch
GROUP BY conversion_id, channel
UNION ALL
SELECT 'time_decay' AS model, conversion_id, channel, SUM(attributed_revenue) AS attributed_revenue
FROM time_decay_norm
GROUP BY conversion_id, channel
ORDER BY model, conversion_id;HardTechnical
97 practiced
You need to aggregate global events into a business day defined as 04:00 - 03:59 local time for each user. Events are stored as UTC timestamps and each user has an `user_timezone` IANA name in a `profiles` table. Write SQL (Postgres) to assign the correct local business date for each event, handling DST transitions. Explain performance and trade-offs of joining on user_timezone strings vs precomputing local_date.
Sample Answer
Approach: convert the UTC event timestamp to the user's IANA timezone, derive the local date, and map business-day as the local date unless the local time is before 04:00 — in that case the business-day is previous local date. Using Postgres' time zone support (AT TIME ZONE) preserves DST handling when you supply the IANA name.SQL (Postgres):Key points / why this works- AT TIME ZONE with an IANA name uses Postgres TZ database and correctly handles DST transitions: e.g., local clock jumps forward/backward; conversion yields the correct local wall-clock time.- Using ::time compares wall-clock time against 04:00 local; subtracting one day yields the intended business day.Performance & trade-offs1) Join on user_timezone strings (above)- Pros: Simple, canonical, always correct (uses up-to-date TZ rules). Minimal data duplication.- Cons: AT TIME ZONE is computed per row and doing this for millions of rows is CPU-heavy. Joining on profiles can be expensive if profiles table is large or not cached. May hurt query planning if user_timezone has high cardinality and no indexes help the function call.2) Precompute local_date / business_date at ingest or in nightly batch- Pros: Fast reads: queries use stored business_date (indexed/partitioned), enabling efficient aggregates and joins. Avoids repeated per-row timezone conversions during analysis.- Cons: Must recompute if profile.timezone changes or when TZ rules update (rare but possible). Adds complexity to data pipeline: you must choose whether to compute on insert (best for append-only events) or nightly backfill. Extra storage cost.Recommendations (Data Scientist context)- For exploratory analyses or small ad-hoc queries, join+AT TIME ZONE is fine and safest.- For production pipelines and repeated large aggregations (daily metrics per business_date), precompute business_date at ingestion or run a scheduled batch that writes business_date into a column (or a materialized view). Index or partition on business_date.- If precomputing, capture the timezone used and a tz_version or tzdata timestamp so you can detect when reprocessing is required after tzdb updates.- Benchmark: measure CPU cost of AT TIME ZONE on slices of your data; if conversion is >10% of query time at scale, prioritize precomputing.Edge cases- Missing/invalid IANA names: fallback to UTC or a default timezone and log.- Historical events spanning tzdb rule changes: if strict historical correctness is required, ensure your Postgres tzdata is up-to-date at computation time or store the computed business_date at ingest.
sql
-- events: id, event_ts_utc (timestamptz)
-- profiles: user_id, user_timezone (text, IANA name like 'America/Los_Angeles')
SELECT
e.id,
e.user_id,
e.event_ts_utc,
-- convert UTC timestamptz to local timestamp without time zone
(e.event_ts_utc AT TIME ZONE p.user_timezone) AS local_ts,
-- compute business_date: if local time < 04:00, subtract 1 day, else take date
(CASE
WHEN (e.event_ts_utc AT TIME ZONE p.user_timezone)::time < time '04:00'
THEN ( (e.event_ts_utc AT TIME ZONE p.user_timezone)::date - INTERVAL '1 day')::date
ELSE (e.event_ts_utc AT TIME ZONE p.user_timezone)::date
END) AS business_date
FROM events e
JOIN profiles p USING (user_id);HardTechnical
55 practiced
Describe how to implement an idempotent incremental ETL using SQL `MERGE` (or `INSERT ... ON CONFLICT`) that supports exactly-once semantics for ingesting incremental batches which might be replayed. Include handling deletes (tombstone records), transaction boundaries, and concurrency control strategies to avoid duplication or inconsistent state.
Sample Answer
Approach (summary)Use an upsert that is deterministic and keyed by the business primary key; record source batch metadata (batch_id, batch_seq or watermark) and tombstone flag. Ensure exactly-once semantics by: (1) deduplicating/replaying at batch granularity using a processed_batches control table, (2) using a transactional advisory lock to serialize batch commits, and (3) using deterministic conflict resolution in MERGE / INSERT ... ON CONFLICT so repeated runs are idempotent.Schema (key columns shown)- target_table(id PRIMARY KEY, payload jsonb, deleted boolean, last_batch_id text, last_seq bigint, updated_at timestamptz)- processed_batches(batch_id text PRIMARY KEY, status text, applied_at timestamptz)Example ingest SQL (Postgres) — assume incoming_rows is a staging table with same keys + batch_id + seq + tombstone boolean:Begin transaction, acquire advisory lockIdempotent upsert using ON CONFLICT with conditional update by batch ordering:Mark batch done and commitKey reasoning / guarantees- processed_batches prevents re-applying the same batch id; status protects partial failures.- Advisory lock serializes concurrent attempts to apply same batch id (or you can lock per-source).- ON CONFLICT logic uses (batch_id, seq) ordering to ensure later events override earlier ones; repeated identical inserts will not change state (idempotent).- Tombstones: incoming_rows.tombstone=true sets deleted=true; you can physically purge via compaction.- Atomicity: single transaction ensures either whole batch visible or not.- Concurrency: advisory locks + deterministic conflict resolution avoid races between overlapping batches.- Failure recovery: if transaction fails, processed_batches remains not-done and can be safely retried.Alternatives / enhancements- Use MERGE if supported; same conditional logic applies.- Use a monotonic numeric watermark instead of batch_id/seq.- For higher throughput, partition locks by key ranges rather than global advisory lock.
BEGIN;
-- serialize batch application to avoid concurrent double-apply
SELECT pg_advisory_xact_lock(hashtext(:batch_id));
-- abort if batch already applied
INSERT INTO processed_batches(batch_id, status, applied_at)
VALUES (:batch_id,'in_progress', now())
ON CONFLICT DO NOTHING;
WITH already AS (
SELECT 1 FROM processed_batches WHERE batch_id = :batch_id AND status = 'done'
)
SELECT CASE WHEN exists (SELECT 1 FROM already) THEN RAISE EXCEPTION 'batch already applied' ELSE 1 END;INSERT INTO target_table(id, payload, deleted, last_batch_id, last_seq, updated_at)
SELECT id, payload, tombstone, batch_id, seq, now()
FROM incoming_rows
ON CONFLICT (id) DO UPDATE
SET
payload = CASE
WHEN (EXCLUDED.last_batch_id = target_table.last_batch_id AND EXCLUDED.last_seq <= target_table.last_seq) THEN target_table.payload
WHEN (EXCLUDED.last_batch_id IS NULL) THEN target_table.payload
ELSE EXCLUDED.payload END,
deleted = CASE
WHEN (EXCLUDED.last_batch_id = target_table.last_batch_id AND EXCLUDED.last_seq <= target_table.last_seq) THEN target_table.deleted
ELSE EXCLUDED.deleted END,
last_batch_id = GREATEST(target_table.last_batch_id, EXCLUDED.last_batch_id),
last_seq = CASE WHEN EXCLUDED.last_batch_id = target_table.last_batch_id THEN GREATEST(target_table.last_seq, EXCLUDED.last_seq) ELSE EXCLUDED.last_seq END,
updated_at = now();UPDATE processed_batches SET status='done', applied_at=now() WHERE batch_id=:batch_id;
COMMIT;Unlock Full Question Bank
Get access to hundreds of SQL for Data Analysis interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.