Data Question Bank …
FA
⊕
Add this page to your home screen It opens like an app, works offline, and keeps your bookmarks and notes.

Question bank for data roles, from basic to advanced

A coherent collection of practical questions in data analysis, data engineering, business intelligence and data science. Every question comes with a complete answer, runnable code, sample output and a practical tip. The page is built both for everyday review and learning and for preparing before an interview, with search, topic filters, practice mode and a notepad.

interview_prep.sql
Which topics should be shown?
My notes
Saved

How to use this page

This collection is written for people working in data: data analysts, data engineers, BI developers, data scientists, product analysts and related roles. It is not limited to interview preparation; you can use it as a daily review reference, a structured learning resource and a practical guide while you work. Every question comes with a complete answer, runnable code, sample output and a practical tip, and within each section the questions are ordered from basic to advanced, so you can start wherever matches your current knowledge.

This page has more than seven hundred questions, so reading it top to bottom is not efficient. The tools below exist for that reason: search and the topic filter narrow the collection down to the few dozen questions you need today; stars let you build your own list for the next review; and each question's unique code or direct link takes you straight back to the same spot next time without searching. A few minutes spent learning these features pays for itself many times over.

Page features

Reading this table once cuts the time to reach any question from minutes to seconds.
/ search
Searches the question and answer text in every section. Shortcut key /, clear with Esc. Search is not case-sensitive.
filter --level
Filters by level: Basic, Intermediate and Advanced. Several levels can be active at once. Within each section, questions are ordered from easy to hard.
★ bookmark
Every question has a star next to it. Starred questions are saved in your browser, and the “Starred” button shows only those; a simple way to build a personal review list.
practice mode
In practice mode, answers are collapsed. Read the question first, form your answer in your head or write it in the notepad, then tap the card to reveal the answer.
copy
Every code block has a copy button. The examples are runnable, so you can run them on your own database or in a notebook.
nav ▾
In the top bar, hovering over a section name opens a submenu of its groups so you can jump straight to the group you want.
✎ notepad
The floating notepad opens with the ✎ button in the top bar. Use it to write your own answers, take notes or draft queries. Drag its title bar to move it; the – button minimizes it, and it can be resized from the bottom corner. Notes are saved automatically in your browser, and the copy button gives you the whole text at once.
Topics ▾
Topic filter: lists every section with its question count, and selecting sections keeps only those. It works together with the level filter and search.
SQL-05 ⏎
Every question has a unique code. Type that code into the search box and press Enter to go straight to the question.
↗ share
The share button next to each question gives you its direct link. Clicking the question code copies the same link.
SELECT … ▸
The SQL panel at the top of the page really runs: edit the query and press Run (or Ctrl+Enter) to see statistics about this page from the interview_prep table.
theme
The button in the corner of the top bar switches between the light and dark themes; it starts out matching your operating system setting.
cheat sheets
Each section starts with a quick-review cheat sheet, syntax-highlighted in that section's language; handy for a compact review and a quick refresher.

A review plan for limited time

The order below follows the typical weight of each area in data roles. Start at the top and go as far as your time allows. The times are for reviewing topics you already know; for a new topic, allow roughly twice as long. Click a topic name to go straight to that section.

TopicQuestionsReview timeFocus on
SQL150 minutesJOIN, window functions, multi-level aggregation, classic problems
Python140 minutespandas, data cleaning, algorithms and live coding
Excel55 minutesLookups, PivotTable, dynamic arrays, LET and LAMBDA
Power BI50 minutesStar schema, relationships, performance and publishing
DAX65 minutesFilter context, CALCULATE, time intelligence
Power Query40 minutesTransformation steps, query folding, the M language
Data Warehouse70 minutesNormalization, dimensional modeling, SCD, fact table types
ETL and pipelines55 minutesIncremental loading, CDC, idempotency, monitoring
Key metrics (KPI)45 minutesMetric definition, funnels, retention, LTV and CAC
Machine Learning60 minutesChoosing metrics, data leakage, model interpretation
Big Data45 minutesSpark, columnar formats, lake architecture
ClickHouse45 minutesTable engine, sort key, materialized views, real-time analytics
Kafka40 minutesPartitions, consumer groups, delivery guarantees
Metabase35 minutesParameters, Models, dashboard optimization
Dynamics 36540 minutesData model, data export paths, Power BI connection
Linux35 minutesawk and grep, cron, safe scripting
Git35 minutesmerge and rebase, reflog, bisect
Behavioral and closing30 minutesThree ready STAR stories and your own questions
TotalAbout 17 hoursFour four-hour sessions

A few rules that work in every technical interview

Think out loud before you write code. Say what the input is, what the output looks like and which edge cases exist: empty values, duplicate records, division by zero, out-of-range data. Many interviewers score this part more than the final code.
State your assumptions. A sentence like “I assumed cancelled orders should not count toward revenue” shows you think about the business meaning of the data, not only the syntax.
When you don't know the answer, don't bluff. Say you're not sure and explain how you would approach it. False confidence hurts you more than not knowing.

SQL

SQL is the most widely shared skill across every data role. All examples in this section use the PostgreSQL dialect, and important differences from MySQL are noted where needed. The sample schema used in the coding exercises comes right after the cheat sheet.

SQL cheat sheet

Last-minute review — everything you usually need right there at the whiteboard.SQL
SELECT ... FROM ... WHERE GROUP BY ... HAVING ... ORDER BY ... LIMIT n OFFSET m
Query writing order
FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → DISTINCT → ORDER BY → LIMIT
Logical execution order; that is why an alias defined in SELECT is not available in WHERE but is in ORDER BY
INNER / LEFT / RIGHT / FULL JOIN
Intersection / all left / all right / all of both sides
CROSS JOIN
Cartesian product; useful for building a calendar × user grid
COUNT(*) vs COUNT(col)
The first counts all rows, the second only non-NULL rows
COUNT(DISTINCT col)
Counts distinct values
COALESCE(a, b, 0)
First non-NULL value
NULLIF(a, 0)
Returns NULL if equal — guards against division by zero
CASE WHEN x > 10 THEN 'A' WHEN x > 5 THEN 'B' ELSE 'C' END
Inline condition; can be used in GROUP BY too
WITH t AS (...) SELECT * FROM t
CTE — makes a query readable and step-by-step
WITH RECURSIVE t AS (...)
Walks a hierarchy (org chart, category tree)
ROW_NUMBER() OVER ( PARTITION BY x ORDER BY y)
Unique row number within each group
RANK() / DENSE_RANK()
Rank with gaps / without gaps on ties
LAG(col, 1) / LEAD(col, 1)
Value of the previous / next row
SUM(x) OVER (ORDER BY d ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
Cumulative sum (running total)
AVG(x) OVER (ORDER BY d ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)
7-day moving average
NTILE(4) OVER (ORDER BY x)
Split into quartiles
DATE_TRUNC('month', ts)
Truncate a date to the month
ts::date, EXTRACT(dow FROM ts)
Type casting, extracting the day of week
ts + INTERVAL '7 days'
Interval arithmetic
UNION / UNION ALL
With duplicate removal / without (faster)
EXISTS / NOT EXISTS
Usually safer and faster than IN and NOT IN
EXPLAIN ANALYZE ...
Shows the actual execution plan and the time of each step

Sample schema for the exercises

Every live-coding question in the SQL section is written against these tables.SQL
customers  (customer_id PK, name, city, signup_date)
orders     (order_id PK, customer_id FK, order_date, status, amount)
order_items(order_id FK, product_id FK, qty, unit_price)
products   (product_id PK, product_name, category, price)
events     (event_id PK, user_id, event_name, event_time)
employees  (emp_id PK, name, manager_id FK -> employees.emp_id, dept, salary)

Conceptual SQL questions 48

S01

What is the difference between WHERE and HAVING?

▾Basic

WHERE is applied to individual rows before grouping; HAVING is applied after grouping, to the results of aggregate functions.

-- Customers with more than 5 orders in 2024
SELECT customer_id, COUNT(*) AS cnt
FROM orders
WHERE order_date >= '2024-01-01'   -- row filter, before grouping
GROUP BY customer_id
HAVING COUNT(*) > 5;               -- group filter, after grouping
Sample output
customer_id | cnt ------------+----- 102 | 9 118 | 7

If you move the order_date condition into HAVING, the database groups every row first and filters afterwards — which is slower. Always put row filters in WHERE.

S02

Explain the types of JOIN and say where LEFT JOIN is useful.

▾Basic
  • INNER JOIN — only rows that match in both tables.
  • LEFT JOIN — every row of the left table; unmatched ones are filled with NULL.
  • RIGHT JOIN — the mirror image of LEFT.
  • FULL OUTER JOIN — every row from both sides.
  • CROSS JOIN — Cartesian product, no condition.
  • SELF JOIN — a table joined to itself (for example, an employee and their manager).
-- Customers who have never placed an order (anti-join)
SELECT c.customer_id, c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
WHERE o.order_id IS NULL;
customer_id | name ------------+-------- 204 | Samira 231 | Kaveh

Common trap: if you put a condition on the right table in WHERE, the LEFT JOIN effectively becomes an INNER JOIN. Conditions on the right table belong inside ON.

S03

UNION vs UNION ALL?

▾Basic

UNION removes duplicate rows (which means an extra sort/hash step); UNION ALL keeps everything and is much faster.

SELECT 'active' AS seg, customer_id FROM active_users
UNION ALL
SELECT 'churned',       customer_id FROM churned_users;

Rule: if you're sure there are no duplicates, or they don't matter, always use UNION ALL.

S04

What is the logical execution order of a SQL query?

▾Basic

FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → DISTINCT → ORDER BY → LIMIT

SELECT city, COUNT(*) AS n
FROM customers
WHERE signup_date >= '2024-01-01'
GROUP BY city
HAVING COUNT(*) > 10
ORDER BY n DESC     -- the alias “n” works here
LIMIT 5;

Because SELECT runs after WHERE, you can't use an alias in WHERE; but ORDER BY comes after SELECT, so the alias is allowed there. This is exactly what the interviewer is looking for.

S05

How does NULL behave in SQL?

▾Basic

NULL means “unknown”, not zero and not an empty string. Any comparison with NULL yields UNKNOWN.

SELECT NULL = NULL      AS eq,     -- NULL (not true)
       NULL IS NULL     AS is_null,-- true
       1 + NULL         AS math,   -- NULL
       COUNT(*)         AS c_all,
       COUNT(amount)    AS c_amt   -- NULLs are not counted
FROM orders;
eq | is_null | math | c_all | c_amt -----+---------+------+-------+------ NULL | t | NULL | 1000 | 987
Classic trap: with NOT IN (subquery), if the subquery returns even one NULL, the whole result is empty. Write NOT EXISTS instead.
S06

Difference between DELETE, TRUNCATE and DROP?

▾Basic
TypeWHERE clauseRollbackTable structure
DELETEDMLYesYesKept
TRUNCATEDDLNoYes in Postgres, not in MySQLKept
DROPDDLNoYes in PostgresRemoved

TRUNCATE is much faster than DELETE for emptying staging tables in an ETL pipeline, because it doesn't write a log entry per row.

Why is SELECT * discouraged in production code?

▾Basic
  • It reads columns you don't need; on a wide table, I/O and network costs go up.
  • It prevents the use of a covering index.
  • If a column is added to the table or reordered, downstream code breaks silently.
  • In a JOIN it produces ambiguous columns with the same name.
SELECT o.order_id, o.amount, c.city
FROM orders o JOIN customers c USING (customer_id);

The only acceptable place for it is quick, manual data exploration, not a query that lives in a dashboard or pipeline.

What does DISTINCT do and what does it cost?

▾Basic
SELECT DISTINCT city FROM customers;
SELECT DISTINCT city, status FROM orders;   -- the combination of both columns becomes unique
city -------- Tehran Mashhad Isfahan

DISTINCT adds an extra sort or hash step. If you've had to use it to remove duplicate rows after a JOIN, it usually means you got the relationship between the tables wrong — better to fix the root cause.

Write a custom sort order with CASE in ORDER BY.

▾Basic
SELECT order_id, status
FROM orders
ORDER BY CASE status
           WHEN 'pending'   THEN 1
           WHEN 'paid'      THEN 2
           WHEN 'shipped'   THEN 3
           ELSE 4
         END,
         order_date DESC;
order_id | status ---------+--------- 9412 | pending 9308 | paid 9201 | shipped

When the logical order differs from alphabetical order, this pattern does the job. A cleaner alternative is a small status table with a sort_order column.

How does BETWEEN differ from explicit comparisons when filtering dates?

▾Basic
-- Dangerous if the column is a timestamp: the hours of the 31st are lost
WHERE order_date BETWEEN '2024-08-01' AND '2024-08-31'

-- Safe and correct
WHERE order_date >= '2024-08-01'
  AND order_date <  '2024-09-01'

BETWEEN includes both ends of the range, but '2024-08-31' as a timestamp means midnight at the start of that day. The pattern “greater than or equal to the start, less than the start of the next period” is always correct, and it can use an index.

How do a primary key, a foreign key and a unique key differ?

▾Basic
Per tableNULLPurpose
PRIMARY KEYOneNot allowedUnique row identifier
UNIQUEManyUsually allowedPrevents duplicate values
FOREIGN KEYManyAllowedReferences another table and preserves integrity
CREATE TABLE orders (
  order_id    bigserial PRIMARY KEY,
  customer_id int NOT NULL REFERENCES customers(customer_id),
  tracking_no text UNIQUE,
  amount      numeric(14,2) NOT NULL CHECK (amount >= 0)
);

Show adding a CHECK constraint too; it shows you think about data quality at the database layer, not only in the report.

Which string functions are most useful for data cleaning?

▾Basic
SELECT
  TRIM(name)                        AS clean_name,
  UPPER(LEFT(city, 1)) || LOWER(SUBSTRING(city FROM 2)) AS pretty_city,
  LENGTH(phone)                     AS phone_len,
  REPLACE(phone, '-', '')           AS phone_digits,
  SPLIT_PART(email, '@', 2)         AS domain,
  POSITION('@' IN email)            AS at_pos
FROM customers;
clean_name | pretty_city | phone_len | phone_digits | domain -----------+-------------+-----------+--------------+----------- Maryam | Tehran | 13 | 09121234567 | gmail.com

In MySQL you use CONCAT() instead of || and SUBSTRING_INDEX instead of SPLIT_PART. If they ask about dialects, mention exactly these differences.

Show the most useful date functions.

▾Basic
SELECT
  CURRENT_DATE                                AS today,
  DATE_TRUNC('week',  order_date)::date       AS week_start,
  EXTRACT(dow  FROM order_date)               AS day_of_week,
  EXTRACT(hour FROM order_time)               AS hour_of_day,
  order_date + INTERVAL '30 days'             AS due_date,
  AGE(CURRENT_DATE, signup_date)              AS tenure,
  (CURRENT_DATE - signup_date)                AS days_since_signup
FROM orders;
today | week_start | day_of_week | days_since_signup -----------+------------+-------------+------------------ 2024-08-31 | 2024-08-26 | 6 | 412

In Postgres, subtracting two date values gives an integer number of days, but subtracting two timestamp values returns an interval. This difference is a source of silent bugs in reports.

How do a temporary table, a CTE and a view differ?

▾Basic
LifetimeIndexableBest for
CTEThat query onlyNoReadability and staging
TEMP TABLEUntil the session endsYesA heavy intermediate result used several times
VIEWPermanentNo (not itself)Shared logic and a single definition
CREATE TEMP TABLE tmp_active AS
SELECT customer_id FROM orders WHERE order_date >= CURRENT_DATE - 30;
CREATE INDEX ON tmp_active (customer_id);

What is a relational database, and what do table, row and column mean?

▾Basic

A relational database stores information in tables that are linked by keys. Each table is an entity (customer, order), each row is one instance of that entity, and each column is an attribute with a defined data type.

CREATE TABLE customers (
    customer_id  serial PRIMARY KEY,   -- unique identifier, auto-increment
    name         text NOT NULL,        -- must not be empty
    city         text,
    signup_date  date DEFAULT CURRENT_DATE
);

INSERT INTO customers (name, city) VALUES ('Maryam', 'Tehran'), ('Kaveh', 'Mashhad');
SELECT * FROM customers;
customer_id | name | city | signup_date ------------+--------+---------+------------ 1 | Maryam | Tehran | 2024-08-31 2 | Kaveh | Mashhad | 2024-08-31

The power of this model comes from separating data: customer information is stored once, and orders refer to it through customer_id. If a customer's city changes, only one row changes.

Write the simplest SELECT query and explain its parts.

▾Basic
SELECT   name, city          -- which columns I want
FROM     customers           -- from which table
WHERE    city = 'Tehran'     -- which rows
ORDER BY name                -- in what order
LIMIT    10;                 -- how many rows
name | city -------+------- Maryam | Tehran Sara | Tehran

These five clauses are the skeleton of almost every query. SELECT and FROM are required; the rest are optional.

In SQL, strings are written in single quotes, not double quotes. Double quotes are for column and table names — one of the first mistakes beginners make.

What are the comparison operators in WHERE?

▾Basic
WHERE amount > 500000                       -- greater than
WHERE status <> 'cancelled'                 -- not equal (or !=)
WHERE city IN ('Tehran', 'Mashhad', 'Shiraz') -- member of a set
WHERE amount BETWEEN 100000 AND 500000      -- in range, both ends included
WHERE name LIKE 'Moh%'                      -- starts with
WHERE phone IS NULL                         -- is empty
WHERE city = 'Tehran' AND amount > 100000   -- combining conditions
WHERE (city = 'Tehran' OR city = 'Karaj') AND status = 'paid'
Parentheses are vital when combining AND and OR. Because AND has higher precedence, without parentheses the last expression is read as “Tehran, or (Karaj and paid)”, which means something completely different.

Show the basic aggregate functions with an example.

▾Basic
SELECT COUNT(*)          AS total_rows,
       COUNT(amount)     AS non_null_amounts,
       COUNT(DISTINCT customer_id) AS unique_customers,
       SUM(amount)       AS revenue,
       AVG(amount)       AS avg_amount,
       MIN(amount)       AS smallest,
       MAX(amount)       AS largest
FROM orders
WHERE status = 'paid';
total_rows | non_null_amounts | unique_customers | revenue | avg_amount | smallest | largest -----------+------------------+------------------+------------+------------+----------+--------- 41200 | 41187 | 18940 | 7.68e+09 | 186,420 | 5,000 | 12,400,000

Aggregate functions always ignore NULL values. That is why COUNT(*) and COUNT(amount) differ, and why an average is computed over the number of non-empty values, not over all rows.

What exactly does GROUP BY do?

▾Basic

It collapses rows that share the same values in the grouping columns into one row, and aggregate functions run separately on each group.

SELECT city,
       status,
       COUNT(*)    AS orders,
       SUM(amount) AS revenue
FROM orders JOIN customers USING (customer_id)
GROUP BY city, status
ORDER BY city, revenue DESC;
city | status | orders | revenue --------+-----------+--------+------------ Tehran | paid | 1120 | 183,000,000 Tehran | cancelled | 140 | 21,000,000 Mashhad | paid | 402 | 58,000,000
Golden rule: every column in SELECT that is not inside an aggregate function must also be in GROUP BY. In Postgres, breaking this rule raises an error; MySQL may silently return an arbitrary value, which is more dangerous.

Build a category column with CASE.

▾Basic
SELECT order_id,
       amount,
       CASE
         WHEN amount >= 1000000 THEN 'large'
         WHEN amount >=  300000 THEN 'medium'
         WHEN amount >       0  THEN 'small'
         ELSE 'invalid'
       END AS size_band
FROM orders
LIMIT 4;
order_id | amount | size_band ---------+-----------+---------- 9001 | 1,250,000 | large 9002 | 450,000 | medium 9003 | 80,000 | small 9004 | 0 | invalid

Conditions are checked top to bottom and the first true one wins. If you write them in reverse order, everything lands in the first bucket. Always start with the strictest condition.

What is an alias and why do we use it?

▾Basic
SELECT c.name         AS customer_name,
       o.amount       AS order_amount,
       o.amount * 0.09 AS tax
FROM   orders    AS o
JOIN   customers AS c ON c.customer_id = o.customer_id;
  • It makes output column names readable, especially for computed columns, which otherwise get meaningless names.
  • Table aliases keep multi-table queries short and resolve ambiguity between columns with the same name.

The AS keyword is optional, but writing it improves readability. For table aliases, the table's first letter (o for orders) is a common convention.

How do you fetch only the first few rows?

▾Basic
SELECT * FROM orders ORDER BY amount DESC LIMIT 5;         -- Postgres, MySQL
SELECT TOP 5 * FROM orders ORDER BY amount DESC;           -- SQL Server
SELECT * FROM orders ORDER BY amount DESC FETCH FIRST 5 ROWS ONLY;  -- standard
Using LIMIT without ORDER BY is a common mistake: without a defined order, the database makes no promise to return the same rows on the next run. If the “first 5” must be stable, always specify a sort.

Write a simple JOIN and explain it step by step.

▾Basic
SELECT o.order_id, c.name, o.amount
FROM orders o
JOIN customers c
  ON c.customer_id = o.customer_id;
order_id | name | amount ---------+--------+-------- 9001 | Maryam | 850000 9002 | Kaveh | 320000

For each row in orders, the database looks up the matching row in customers. The ON condition says what the match is based on.

If you forget the ON condition, the result is a Cartesian product: every row of the first table paired with every row of the second. With two thousand-row tables, you get a million rows. If the output is unexpectedly large, check this first.

Show ascending and descending order and multi-column sorting.

▾Basic
SELECT city, name, amount
FROM orders JOIN customers USING (customer_id)
ORDER BY city ASC,            -- first by city, ascending (default)
         amount DESC,         -- within each city, amount descending
         name;                -- on ties, by name
city | name | amount --------+--------+--------- Tehran | Maryam | 1,250,000 Tehran | Sara | 840,000 Mashhad | Kaveh | 620,000

You can also write a column's position instead of its name (ORDER BY 1, 3 DESC), which is quick in exploratory queries, but in lasting code write the column name so nothing breaks when the SELECT order changes.

S07

What is a window function and how does it differ from GROUP BY?

▾Intermediate

GROUP BY collapses rows together; a window function performs the aggregate calculation but keeps the number of rows.

SELECT order_id, customer_id, amount,
       SUM(amount)  OVER (PARTITION BY customer_id) AS total_by_cust,
       amount / SUM(amount) OVER (PARTITION BY customer_id) AS share
FROM orders;
order_id | customer_id | amount | total_by_cust | share ---------+-------------+--------+---------------+------- 9001 | 102 | 250000 | 1000000 | 0.25 9002 | 102 | 750000 | 1000000 | 0.75

Whenever you want “each row's share of its group total” or “comparison with the group average” without losing rows, a window function is the answer.

S08

Difference between ROW_NUMBER, RANK and DENSE_RANK?

▾Intermediate
SELECT name, score,
       ROW_NUMBER() OVER (ORDER BY score DESC) AS rn,
       RANK()       OVER (ORDER BY score DESC) AS rnk,
       DENSE_RANK() OVER (ORDER BY score DESC) AS drnk
FROM players;
name | score | rn | rnk | drnk ------+-------+----+-----+----- ali | 95 | 1 | 1 | 1 sara | 95 | 2 | 1 | 1 reza | 90 | 3 | 3 | 2 mina | 80 | 4 | 4 | 3

ROW_NUMBER is always unique; RANK skips after ties (1, 1, 3); DENSE_RANK doesn't skip (1, 1, 2). Use ROW_NUMBER to “remove duplicate records” and DENSE_RANK for “true ranking”.

S09

What is a CTE, and when do you use one instead of a subquery?

▾Intermediate

A CTE is WITH name AS (...): a named temporary result that makes a query step-by-step and readable, and can be referenced several times.

WITH monthly AS (
    SELECT DATE_TRUNC('month', order_date) AS m, SUM(amount) AS rev
    FROM orders WHERE status = 'paid'
    GROUP BY 1
), with_growth AS (
    SELECT m, rev, LAG(rev) OVER (ORDER BY m) AS prev_rev
    FROM monthly
)
SELECT m, rev, ROUND(100.0 * (rev - prev_rev) / NULLIF(prev_rev,0), 1) AS growth_pct
FROM with_growth ORDER BY m;
m | rev | prev_rev | growth_pct -----------+----------+----------+----------- 2024-01-01 | 12000000 | NULL | NULL 2024-02-01 | 15000000 | 12000000 | 25.0 2024-03-01 | 13500000 | 15000000 | -10.0

In Postgres versions before 12, a CTE was always materialized (an optimization fence). From 12 onward the default is inline unless you write MATERIALIZED. If they ask “are CTEs slow?”, say exactly this.

S10

What is an index, and when doesn't it help?

▾Intermediate

An index (usually a B-Tree) is a sorted structure that brings lookups down from O(n) to O(log n), but it slows down writes and takes up space.

The index is not used when:

  • You apply a function to the indexed column: WHERE YEAR(order_date)=2024 → write a range instead.
  • LIKE with a leading %: LIKE '%foo'.
  • Cardinality is low (for example a gender column) and a large share of the table comes back; then a Seq Scan is cheaper.
  • An implicit type conversion happens (comparing a numeric column with a string).
-- Bad: the index has no effect
WHERE DATE_TRUNC('year', order_date) = '2024-01-01'
-- Good: sargable
WHERE order_date >= '2024-01-01' AND order_date < '2025-01-01'

Using the term sargable earns you points: a condition the engine can apply directly on the index.

S11

Explain a composite index and the “leftmost prefix” rule.

▾Intermediate
CREATE INDEX idx_orders ON orders (customer_id, order_date);
  • A query with WHERE customer_id = 5 → the index is used.
  • With customer_id = 5 AND order_date > '2024-01-01' → it is fully used.
  • With only order_date > ... → usually not used, because the first column isn't in the condition.

Order the columns as “equality columns first, range column after”.

S12

How do you read the output of EXPLAIN ANALYZE?

▾Intermediate
EXPLAIN ANALYZE
SELECT c.city, COUNT(*) FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
WHERE o.order_date >= '2024-01-01' GROUP BY c.city;
HashAggregate (cost=1250.4..1252.9 rows=25) (actual time=18.3..18.4 rows=25 loops=1) -> Hash Join (cost=45.2..1180.1 rows=14060) (actual time=0.9..14.2 rows=13980) -> Seq Scan on orders o (actual time=0.0..6.1 rows=13980) Filter: (order_date >= '2024-01-01') Rows Removed by Filter: 36020 -> Hash (actual time=0.8..0.8 rows=500) -> Seq Scan on customers c (actual rows=500) Planning Time: 0.3 ms Execution Time: 19.1 ms

What you look for:

  • A big gap between estimated rows and actual rows → statistics are stale; run ANALYZE.
  • Seq Scan on a large table with a selective filter → a candidate for an index.
  • High Rows Removed by Filter → the filter is applied too late.
  • Nested Loop with many loops over a large table → usually a sign of trouble.
S13

What is normalization, and why do we sometimes denormalize on purpose in a data warehouse?

▾Intermediate
  • 1NF atomic values, no repeating groups.
  • 2NF 1NF + full dependency on the whole primary key.
  • 3NF 2NF + no transitive dependency (non-key column on non-key column).

In OLTP, normalization reduces redundancy and inconsistency. In OLAP and data warehouses, many JOINs are expensive, so with a star schema we deliberately repeat some columns in dimension tables to make analytical queries fast.

If the role is analytics/BI, make sure you know the star schema and the difference between a fact table and a dimension table — a fact holds numeric measures and foreign keys, a dimension holds descriptive attributes.

S14

OLTP vs OLAP?

▾Intermediate
OLTPOLAP
PurposeRecording transactionsAnalysis and reporting
Query patternReading/writing a few rowsColumnar scans over large volumes
Data modelNormalizedStar / denormalized
StorageRow-based (Postgres, MySQL)Columnar (ClickHouse, BigQuery)
ExamplePlacing an orderMonthly revenue by city
S15

Difference between IN, EXISTS and JOIN for filtering?

▾Intermediate
-- 1. IN
SELECT * FROM customers WHERE customer_id IN (SELECT customer_id FROM orders);
-- 2. EXISTS (correlated)
SELECT * FROM customers c
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id);
-- 3. JOIN — watch out for row multiplication
SELECT DISTINCT c.* FROM customers c JOIN orders o USING (customer_id);

EXISTS stops as soon as it finds the first match and is safe with NULLs. A JOIN multiplies left rows if the right table has several matching rows — the classic “why did my total sales double” trap.

S16

What does ACID mean?

▾Intermediate
  • Atomicity — a transaction either completes fully or not at all.
  • Consistency — the database moves from one valid state to another valid state.
  • Isolation — concurrent transactions don't affect each other in unintended ways.
  • Durability — after COMMIT, the data survives even a power failure.

Isolation levels from weakest to strongest: READ UNCOMMITTED → READ COMMITTED (Postgres default) → REPEATABLE READ → SERIALIZABLE. The matching problems: dirty read, non-repeatable read, phantom read.

Compare a scalar subquery, a correlated subquery and a subquery in FROM.

▾Intermediate
-- 1) Scalar: returns one value, runs once
SELECT name, (SELECT AVG(amount) FROM orders) AS overall_avg FROM customers;

-- 2) Correlated: depends on the outer row, evaluated per row
SELECT c.name,
       (SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.customer_id) AS n
FROM customers c;

-- 3) In FROM (derived table): behaves like a temporary table
SELECT city, AVG(n) FROM (
  SELECT customer_id, city, COUNT(*) AS n
  FROM orders JOIN customers USING (customer_id) GROUP BY 1, 2
) t GROUP BY city;

The second is the most expensive. It can usually be rewritten as a LEFT JOIN + GROUP BY, which gets a much better plan.

What is wrong with paging using LIMIT/OFFSET?

▾Intermediate
-- Slow on deep pages: it must read and discard 100,000 rows
SELECT * FROM orders ORDER BY order_id LIMIT 20 OFFSET 100000;

-- Keyset pagination: fast and stable
SELECT * FROM orders
WHERE order_id > :last_seen_id
ORDER BY order_id
LIMIT 20;

The second problem with OFFSET is that if a record is inserted between two requests, some rows are repeated or skipped. The keyset approach solves both problems.

Write multi-level grouping with GROUPING SETS and ROLLUP.

▾Intermediate
SELECT COALESCE(city, 'All cities') AS city,
       COALESCE(status, 'all')    AS status,
       SUM(amount) AS revenue
FROM orders JOIN customers USING (customer_id)
GROUP BY ROLLUP (city, status)
ORDER BY city NULLS LAST;
city | status | revenue -----------+--------+---------- Tehran | paid | 183000000 Tehran | all | 201000000 All cities | all | 292000000

In a single scan you get the details as well as the subtotals and the grand total. The alternative is a UNION ALL of several queries, which costs several times as much.

Difference between LIKE, ILIKE and full-text search?

▾Intermediate
WHERE name LIKE 'Moh%'         -- starts with, case-sensitive
WHERE email ILIKE '%@GMAIL.COM' -- case-insensitive (Postgres)
WHERE name ~ '^Moh'             -- regular expression
WHERE to_tsvector('simple', body) @@ plainto_tsquery('simple', 'discount')

The pattern 'x%' can use a B-Tree index but '%x' cannot. To search the middle of a text you need a trigram index or full-text search.

Where is type casting (CAST) needed, and what is the risk?

▾Intermediate
SELECT 7 / 2            AS int_div,      -- 3  integer division
       7.0 / 2          AS real_div,     -- 3.5
       CAST(7 AS numeric) / 2 AS explicit,
       '2024-08-31'::date AS d,
       amount::text     AS as_text
FROM orders LIMIT 1;
int_div | real_div | explicit | d | as_text --------+----------+----------+------------+--------- 3 | 3.5 | 3.5 | 2024-08-31 | 850000.00
Dividing two integers in Postgres gives an integer result. Computing a percentage without casting always returns zero or a wrongly rounded number — one of the most frequent bugs in reporting.

How do you write a transaction, and why does it matter?

▾Intermediate
BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;
  -- If either one fails:
  -- ROLLBACK;
COMMIT;

It is useful in data pipelines too: put deleting a day's partition and re-inserting it in one transaction so users never see a half-finished state (an empty table).

S18

What is the difference between RANGE and ROWS in a window frame?

▾Advanced
SUM(amount) OVER (ORDER BY d ROWS  BETWEEN 2 PRECEDING AND CURRENT ROW)  -- 3 physical rows
SUM(amount) OVER (ORDER BY d RANGE BETWEEN 2 PRECEDING AND CURRENT ROW)  -- based on value

ROWS counts rows; RANGE works on the value of the ORDER BY column and takes rows with equal values (peers) together.

When you write ORDER BY, the default frame is RANGE UNBOUNDED PRECEDING AND CURRENT ROW, not ROWS. If you have duplicate dates, the running total comes out wrong. For cumulative sums, always write ROWS explicitly.
S19

What is a recursive CTE? Give a practical example.

▾Advanced
WITH RECURSIVE org AS (
    SELECT emp_id, name, manager_id, 1 AS lvl          -- anchor
    FROM employees WHERE manager_id IS NULL
    UNION ALL
    SELECT e.emp_id, e.name, e.manager_id, o.lvl + 1   -- recursive
    FROM employees e JOIN org o ON e.manager_id = o.emp_id
)
SELECT lvl, name FROM org ORDER BY lvl, name;
lvl | name ----+--------------- 1 | CEO 2 | CTO 2 | CFO 3 | Data analyst

Other uses: generating a date series to fill in missing days, walking a product category tree, computing a BOM in manufacturing.

S20

What is the N+1 problem in queries and how do you fix it?

▾Advanced

When a separate query runs for each row of a query's result (usually in an ORM or a Python loop). Instead of 1 query, N+1 queries run.

# Bad
for cid in customer_ids:                      # N queries
    cur.execute("SELECT SUM(amount) FROM orders WHERE customer_id = %s", (cid,))

# Good
cur.execute("""SELECT customer_id, SUM(amount) FROM orders
               WHERE customer_id = ANY(%s) GROUP BY customer_id""", (customer_ids,))

If the role is data engineer, tie this to “batching” and “pushing computation down to the database instead of Python”.

S21

How does a materialized view differ from a view?

▾Advanced

A view is just a stored query and runs from scratch every time. A materialized view keeps the result on disk; it is fast but has to be refreshed.

CREATE MATERIALIZED VIEW mv_daily_rev AS
SELECT order_date::date AS d, SUM(amount) AS rev
FROM orders WHERE status='paid' GROUP BY 1;

CREATE UNIQUE INDEX ON mv_daily_rev (d);        -- required for CONCURRENTLY
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_rev;

CONCURRENTLY lets read queries run during the refresh — a good point for Metabase dashboards that must not be locked.

S22

What is table partitioning and when is it needed?

▾Advanced
CREATE TABLE events (event_id bigint, user_id int, event_time timestamptz)
PARTITION BY RANGE (event_time);

CREATE TABLE events_2024_08 PARTITION OF events
  FOR VALUES FROM ('2024-08-01') TO ('2024-09-01');

Benefits: partition pruning (scanning only the relevant partitions), removing old data with DROP TABLE instead of a heavy DELETE, and easier index maintenance.

If you have an event table with hundreds of millions of rows and always filter by time, time-based partitioning is the first solution.

S23

What is SCD Type 2?

▾Advanced

Slowly Changing Dimension Type 2: instead of overwriting the old value, we add a new row with a validity period so history is preserved.

customer_key | customer_id | city   | valid_from | valid_to   | is_current
-------------+-------------+--------+------------+------------+-----------
          1  |         102 | Tehran | 2023-01-01 | 2024-05-31 | false
          2  |         102 | Karaj  | 2024-06-01 | 9999-12-31 | true

Type 1 overwrites the value (no history); Type 3 keeps a “previous value” column.

This question is very common in data warehouse roles. The key sentence: “So historical reports reflect the facts as they were at the time, not today's state.”

S24

You are handed a slow query. What do you do, step by step?

▾Advanced
  1. I run EXPLAIN ANALYZE to see the actual plan and the bottleneck.
  2. I see which step takes the most time and has the biggest gap between estimate and reality.
  3. I check that the conditions are sargable and that a suitable index exists.
  4. I reduce the amount of data returned: remove SELECT *, filter earlier, remove unnecessary DISTINCTs.
  5. I remove unnecessary JOINs and repeated correlated subqueries.
  6. If it is a recurring analytical query, I build a materialized view or an aggregate table.
  7. Finally, ANALYZE, a look at the table statistics, and partitioning if needed.

Memorize this structured answer; it comes up in almost every data interview.

S25

How does COUNT(DISTINCT x) differ from approximate methods?

▾Advanced

COUNT(DISTINCT) is exact but memory-hungry and slow on large volumes. Approximate algorithms such as HyperLogLog work with about 2% error and constant memory.

-- Postgres with the postgresql-hll extension, or in ClickHouse:
SELECT uniqCombined(user_id) FROM events;   -- ClickHouse
SELECT approx_count_distinct(user_id) FROM events;  -- Spark SQL

For DAU/MAU over billions of events, an approximation is perfectly acceptable. If “unique active users” comes up, raise this.

SQL live-coding exercises 28

L01

Find the second-highest salary.

▾Basic
SELECT MAX(salary) AS second_highest
FROM employees
WHERE salary < (SELECT MAX(salary) FROM employees);

-- A more general approach for the Nth:
SELECT DISTINCT salary FROM employees
ORDER BY salary DESC OFFSET 1 LIMIT 1;
second_highest -------------- 42000000

If there is no second salary, the first approach returns NULL and the second returns no rows. Point out this difference yourself.

L02

Return the top 5 products by sales amount.

▾Basic
SELECT p.product_name,
       SUM(oi.qty * oi.unit_price) AS revenue,
       SUM(oi.qty)                 AS units
FROM order_items oi
JOIN products p ON p.product_id = oi.product_id
JOIN orders   o ON o.order_id   = oi.order_id
WHERE o.status = 'paid'
GROUP BY p.product_name
ORDER BY revenue DESC
LIMIT 5;
product_name | revenue | units -------------+-----------+------ Headphones | 480000000 | 1200 Keyboard | 310000000 | 2050

Add the status='paid' filter yourself and say “I assumed cancelled orders shouldn't count toward revenue” — this kind of explicit assumption earns points.

L03

Report monthly revenue for the current year.

▾Basic
SELECT DATE_TRUNC('month', order_date)::date AS month,
       COUNT(*)      AS orders,
       SUM(amount)   AS revenue,
       ROUND(AVG(amount), 0) AS aov
FROM orders
WHERE status = 'paid' AND order_date >= DATE_TRUNC('year', CURRENT_DATE)
GROUP BY 1 ORDER BY 1;
month | orders | revenue | aov -----------+--------+-----------+--------- 2024-01-01 | 820 | 120000000 | 146341 2024-02-01 | 910 | 150000000 | 164835

AOV means Average Order Value. If you use the right names for business metrics, they can tell you've worked with a product team.

L04

Customers who have never ordered.

▾Basic
SELECT c.customer_id, c.name
FROM customers c
WHERE NOT EXISTS (
    SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
);

There are three ways: NOT EXISTS, LEFT JOIN ... IS NULL, and NOT IN. Pick the first and say why: it is safe with NULLs and usually gets a better plan.

Return the number of customers per city, from most to fewest.

▾Basic
SELECT city, COUNT(*) AS customers
FROM customers
GROUP BY city
ORDER BY customers DESC;
city | customers --------+---------- Tehran | 1840 Mashhad | 610 Isfahan | 455

Return the orders from the last seven days with the customer's name.

▾Basic
SELECT o.order_id, c.name, o.order_date, o.amount
FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
WHERE o.order_date >= CURRENT_DATE - INTERVAL '7 days'
ORDER BY o.order_date DESC;
order_id | name | order_date | amount ---------+---------+------------+--------- 9520 | Negar | 2024-08-30 | 620000 9518 | Farhad | 2024-08-30 | 1150000

Join three tables and show the items of each order.

▾Basic
SELECT o.order_id, c.name, p.product_name, oi.qty, oi.unit_price,
       oi.qty * oi.unit_price AS line_total
FROM orders o
JOIN customers   c  ON c.customer_id = o.customer_id
JOIN order_items oi ON oi.order_id   = o.order_id
JOIN products    p  ON p.product_id  = oi.product_id
WHERE o.order_id = 9001;
order_id | name | product_name | qty | unit_price | line_total ---------+--------+--------------+-----+------------+----------- 9001 | Maryam | Headphones | 1 | 850000 | 850000 9001 | Maryam | Cable | 2 | 60000 | 120000

When you JOIN an order with its items, each order is repeated once per item. If you then take SUM(o.amount), the amount is multiplied. Always check this before aggregating.

Compute the percentage share of each order status.

▾Basic
SELECT status,
       COUNT(*) AS n,
       ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 1) AS pct
FROM orders
GROUP BY status
ORDER BY n DESC;
status | n | pct ----------+-------+----- paid | 41200 | 82.4 cancelled | 5300 | 10.6 pending | 3500 | 7.0

The combination SUM(COUNT(*)) OVER () looks odd at first: grouping happens first, then the window takes the grand total over the grouped results.

Find products that have never been sold.

▾Basic
SELECT p.product_id, p.product_name, p.category
FROM products p
LEFT JOIN order_items oi ON oi.product_id = p.product_id
WHERE oi.product_id IS NULL;
product_id | product_name | category -----------+--------------+------------ 88 | Laptop stand | Accessories
L05

For each customer, return their latest order.

▾Intermediate
WITH ranked AS (
  SELECT o.*,
         ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC, order_id DESC) AS rn
  FROM orders o
)
SELECT customer_id, order_id, order_date, amount
FROM ranked WHERE rn = 1;
customer_id | order_id | order_date | amount ------------+----------+------------+-------- 101 | 9310 | 2024-07-21 | 890000 102 | 9288 | 2024-07-19 | 1250000

I added the order_id DESC tie-breaker so the result is deterministic if there are two orders on the same day. In Postgres, the faster way is DISTINCT ON:

SELECT DISTINCT ON (customer_id) *
FROM orders ORDER BY customer_id, order_date DESC;
L07

Build a 7-day moving average.

▾Intermediate
SELECT d, rev,
       ROUND(AVG(rev) OVER (ORDER BY d ROWS BETWEEN 6 PRECEDING AND CURRENT ROW), 0) AS ma7
FROM daily_revenue ORDER BY d;
d | rev | ma7 -----------+--------+------- 2024-08-06 | 620000 | 540000 2024-08-07 | 700000 | 563000

If days without sales are missing from the table, the moving average is wrong. The fix: build a calendar with generate_series and LEFT JOIN to it. Say this so it's clear you've thought about missing data.

SELECT g.d, COALESCE(r.rev, 0) AS rev
FROM generate_series('2024-08-01'::date, '2024-08-31', '1 day') g(d)
LEFT JOIN daily_revenue r ON r.d = g.d;
L08

Compute month-over-month (MoM) growth.

▾Intermediate
WITH m AS (
  SELECT DATE_TRUNC('month', order_date)::date AS mon, SUM(amount) AS rev
  FROM orders WHERE status='paid' GROUP BY 1
)
SELECT mon, rev,
       LAG(rev) OVER (ORDER BY mon) AS prev,
       ROUND(100.0 * (rev - LAG(rev) OVER (ORDER BY mon))
             / NULLIF(LAG(rev) OVER (ORDER BY mon), 0), 1) AS mom_pct
FROM m ORDER BY mon;
mon | rev | prev | mom_pct -----------+----------+----------+-------- 2024-06-01 | 18000000 | 20000000 | -10.0 2024-07-01 | 22500000 | 18000000 | 25.0

NULLIF(x, 0) prevents a division-by-zero error. Always include this detail.

L09

Find the most expensive product in each category.

▾Intermediate
SELECT category, product_name, price
FROM (
  SELECT *, RANK() OVER (PARTITION BY category ORDER BY price DESC) AS r
  FROM products
) t
WHERE r = 1;
category | product_name | price ------------+---------------------+--------- Audio | Pro headphones | 8500000 Accessories | Docking station | 6200000

I deliberately used RANK rather than ROW_NUMBER here, because if two products have the same price both should appear. If the interviewer says “only one”, switch to ROW_NUMBER.

L10

Compute the funnel conversion rate (view → add_to_cart → purchase).

▾Intermediate
WITH f AS (
  SELECT user_id,
         MAX(CASE WHEN event_name='view'         THEN 1 ELSE 0 END) AS viewed,
         MAX(CASE WHEN event_name='add_to_cart'  THEN 1 ELSE 0 END) AS carted,
         MAX(CASE WHEN event_name='purchase'     THEN 1 ELSE 0 END) AS bought
  FROM events
  WHERE event_time >= CURRENT_DATE - INTERVAL '30 days'
  GROUP BY user_id
)
SELECT SUM(viewed) AS viewers, SUM(carted) AS carters, SUM(bought) AS buyers,
       ROUND(100.0*SUM(carted)/NULLIF(SUM(viewed),0), 1)  AS view_to_cart_pct,
       ROUND(100.0*SUM(bought)/NULLIF(SUM(carted),0), 1)  AS cart_to_buy_pct
FROM f;
viewers | carters | buyers | view_to_cart_pct | cart_to_buy_pct --------+---------+--------+------------------+---------------- 52000 | 11400 | 3100 | 21.9 | 27.2

This version doesn't check the order of events. If they want an ordered funnel, compare each step's MIN(event_time) so the next step's time is later. Always state this limitation yourself.

L11

Compute first-month retention.

▾Intermediate
WITH c AS (
  SELECT customer_id, DATE_TRUNC('month', MIN(order_date)) AS cohort
  FROM orders GROUP BY customer_id
), act AS (
  SELECT o.customer_id, c.cohort,
         DATE_TRUNC('month', o.order_date) AS mon
  FROM orders o JOIN c ON c.customer_id = o.customer_id
  GROUP BY 1,2,3
)
SELECT cohort,
       COUNT(DISTINCT customer_id) FILTER (WHERE mon = cohort) AS m0,
       COUNT(DISTINCT customer_id) FILTER (WHERE mon = cohort + INTERVAL '1 month') AS m1,
       ROUND(100.0 * COUNT(DISTINCT customer_id) FILTER (WHERE mon = cohort + INTERVAL '1 month')
             / NULLIF(COUNT(DISTINCT customer_id) FILTER (WHERE mon = cohort), 0), 1) AS retention_pct
FROM act GROUP BY cohort ORDER BY cohort;
cohort | m0 | m1 | retention_pct -----------+------+-----+-------------- 2024-05-01 | 1200 | 384 | 32.0 2024-06-01 | 1450 | 507 | 35.0

FILTER (WHERE ...) is a cleaner, Postgres-specific version of SUM(CASE WHEN ...). In MySQL you'd have to write CASE.

L12

Pivot: sales per city by quarter in separate columns.

▾Intermediate
SELECT c.city,
       SUM(o.amount) FILTER (WHERE EXTRACT(quarter FROM o.order_date)=1) AS q1,
       SUM(o.amount) FILTER (WHERE EXTRACT(quarter FROM o.order_date)=2) AS q2,
       SUM(o.amount) FILTER (WHERE EXTRACT(quarter FROM o.order_date)=3) AS q3,
       SUM(o.amount) FILTER (WHERE EXTRACT(quarter FROM o.order_date)=4) AS q4
FROM orders o JOIN customers c USING (customer_id)
WHERE o.status='paid'
GROUP BY c.city ORDER BY 2 DESC NULLS LAST;
city | q1 | q2 | q3 | q4 --------+----------+----------+----------+---- Tehran | 50000000 | 62000000 | 71000000 | NULL Mashhad | 18000000 | 21000000 | 19000000 | NULL

NULLS LAST matters because in Postgres a descending sort puts NULLs first by default.

Each customer's first purchase date and how long ago it was.

▾Intermediate
SELECT customer_id,
       MIN(order_date)                        AS first_order,
       MAX(order_date)                        AS last_order,
       COUNT(*)                               AS orders,
       CURRENT_DATE - MAX(order_date)         AS days_since_last
FROM orders
WHERE status = 'paid'
GROUP BY customer_id
HAVING COUNT(*) >= 2
ORDER BY days_since_last DESC;
customer_id | first_order | last_order | orders | days_since_last ------------+-------------+------------+--------+---------------- 118 | 2023-02-11 | 2024-01-04 | 4 | 240 102 | 2023-05-02 | 2024-08-19 | 9 | 12

The last column is the recency measure from RFM analysis; the customers at the top of this list are candidates for a win-back campaign.

Segment customers by their total purchases.

▾Intermediate
WITH t AS (
  SELECT customer_id, SUM(amount) AS total
  FROM orders WHERE status = 'paid' GROUP BY customer_id
)
SELECT CASE WHEN total >= 10000000 THEN 'gold'
            WHEN total >=  3000000 THEN 'silver'
            WHEN total >=  500000  THEN 'bronze'
            ELSE 'new' END AS tier,
       COUNT(*)   AS customers,
       SUM(total) AS revenue
FROM t GROUP BY 1
ORDER BY revenue DESC;
tier | customers | revenue ----------+-----------+---------- gold | 180 | 412000000 silver | 940 | 268000000 bronze | 2110 | 91000000

The order of the CASE conditions matters: start with the largest threshold, otherwise everything falls into the first condition.

Compute the average number of items and amount per order without multiplying rows.

▾Intermediate
WITH per_order AS (
  SELECT o.order_id,
         COUNT(oi.product_id)              AS items,
         SUM(oi.qty * oi.unit_price)       AS total
  FROM orders o
  JOIN order_items oi ON oi.order_id = o.order_id
  WHERE o.status = 'paid'
  GROUP BY o.order_id
)
SELECT ROUND(AVG(items), 2) AS avg_items,
       ROUND(AVG(total))    AS avg_basket,
       COUNT(*)             AS orders
FROM per_order;
avg_items | avg_basket | orders ----------+------------+------- 2.41 | 186420 | 41200

The pattern “aggregate at the fine grain first, then aggregate at the higher level” is the standard way to avoid double counting. If you average directly over the JOIN, orders with many items get more weight.

L13

Find users who were active on three consecutive days (gaps and islands).

▾Advanced
WITH d AS (
  SELECT DISTINCT user_id, event_time::date AS d FROM events
), g AS (
  SELECT user_id, d,
         d - (ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY d))::int AS grp
  FROM d
)
SELECT user_id, MIN(d) AS start_d, MAX(d) AS end_d, COUNT(*) AS streak
FROM g GROUP BY user_id, grp
HAVING COUNT(*) >= 3
ORDER BY streak DESC;
user_id | start_d | end_d | streak --------+------------+------------+------- 771 | 2024-08-04 | 2024-08-10 | 7 902 | 2024-08-12 | 2024-08-14 | 3

The key trick: in a continuous run of dates, “date minus row number” stays constant. Memorize this pattern — it is one of the most frequent hard SQL questions.

L15

Orders whose amount is above their own city's average.

▾Advanced
SELECT * FROM (
  SELECT o.order_id, c.city, o.amount,
         AVG(o.amount) OVER (PARTITION BY c.city) AS city_avg
  FROM orders o JOIN customers c USING (customer_id)
) t
WHERE amount > city_avg;
order_id | city | amount | city_avg ---------+---------+----------+---------- 9312 | Tehran | 2500000 | 640000.0 9401 | Mashhad | 1100000 | 520000.0

You can't use a window function directly in WHERE, because windows run after WHERE — so you have to wrap it in a subquery or CTE. Explaining this yourself shows you really understand the execution order.

L16

Cumulative share and Pareto (80/20) analysis on products.

▾Advanced
WITH r AS (
  SELECT p.product_name, SUM(oi.qty*oi.unit_price) AS rev
  FROM order_items oi JOIN products p USING (product_id)
  GROUP BY 1
)
SELECT product_name, rev,
       ROUND(100.0*rev / SUM(rev) OVER (), 2) AS pct,
       ROUND(100.0*SUM(rev) OVER (ORDER BY rev DESC ROWS UNBOUNDED PRECEDING)
             / SUM(rev) OVER (), 2) AS cum_pct
FROM r ORDER BY rev DESC;
product_name | rev | pct | cum_pct -------------+-----------+-------+-------- Headphones | 480000000 | 32.10 | 32.10 Keyboard | 310000000 | 20.70 | 52.80 Mouse | 220000000 | 14.70 | 67.50

SUM(rev) OVER () without PARTITION means “grand total” — a very useful pattern for computing shares.

L17

The time gap between each customer's consecutive orders.

▾Advanced
SELECT customer_id, order_id, order_date,
       LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) AS prev_order,
       order_date - LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) AS days_gap
FROM orders ORDER BY customer_id, order_date;
customer_id | order_id | order_date | prev_order | days_gap ------------+----------+------------+------------+--------- 101 | 9001 | 2024-03-02 | NULL | NULL 101 | 9105 | 2024-04-11 | 2024-03-02 | 40

The average of this column is the “purchase cycle” measure. If they ask about churn analysis, say: a user whose current gap is several times their own historical average is a churn candidate.

L18

Deduplication, keeping the newest version of each record.

▾Advanced
SELECT DISTINCT ON (user_id) user_id, email, updated_at
FROM user_snapshots
ORDER BY user_id, updated_at DESC;

-- Portable equivalent for other databases
SELECT user_id, email, updated_at FROM (
  SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY updated_at DESC) rn
  FROM user_snapshots) t WHERE rn = 1;

This is exactly what you do in a CDC pipeline (database changes from Kafka) to get “the latest state of each record”. If Kafka is in the job description, make this connection.

L19

Market basket: products bought together (self join).

▾Advanced
SELECT a.product_id AS p1, b.product_id AS p2, COUNT(*) AS together
FROM order_items a
JOIN order_items b ON a.order_id = b.order_id AND a.product_id < b.product_id
GROUP BY 1,2
ORDER BY together DESC
LIMIT 10;
p1 | p2 | together ---+----+--------- 12| 45 | 318 12| 61 | 207

The condition a.product_id < b.product_id removes both the duplicate pairs (A,B) and (B,A) and the pairing of a product with itself. Always explain why.

L20

Sessionization: split events into sessions wherever the gap exceeds 30 minutes.

▾Advanced
WITH e AS (
  SELECT user_id, event_time,
         LAG(event_time) OVER (PARTITION BY user_id ORDER BY event_time) AS prev_t
  FROM events
), flagged AS (
  SELECT *, CASE WHEN prev_t IS NULL
                   OR event_time - prev_t > INTERVAL '30 minutes'
                 THEN 1 ELSE 0 END AS is_new
  FROM e
)
SELECT user_id, event_time,
       SUM(is_new) OVER (PARTITION BY user_id ORDER BY event_time
                         ROWS UNBOUNDED PRECEDING) AS session_id
FROM flagged;
user_id | event_time | session_id --------+---------------------+----------- 771 | 2024-08-04 09:00:00 | 1 771 | 2024-08-04 09:12:00 | 1 771 | 2024-08-04 11:40:00 | 2

The “flag + running sum over the flag” pattern is the standard way to build groups. The same technique is used to find continuous periods.

L21

Update a table with MERGE / upsert.

▾Advanced
-- Postgres
INSERT INTO dim_customer (customer_id, city, updated_at)
VALUES (102, 'Karaj', NOW())
ON CONFLICT (customer_id)
DO UPDATE SET city = EXCLUDED.city, updated_at = EXCLUDED.updated_at
WHERE dim_customer.city IS DISTINCT FROM EXCLUDED.city;
INSERT 0 1

The IS DISTINCT FROM condition avoids pointless writes and, unlike <>, works correctly with NULL. It is vital for keeping an ETL pipeline idempotent.

L22

Scenario: a manager says “sales dropped last week”. How do you investigate with SQL?

▾Advanced

Structure of the answer (say this out loud; the interviewer is waiting for the method, not just the code):

  1. Confirm the problem: is it a real drop or normal fluctuation? Compare with the previous week and the same week last year.
  2. Decompose: is the drop in the number of orders or in AOV? revenue = orders × AOV.
  3. Slice by dimensions: break it down by city, channel, device, product category, new versus returning users.
  4. Data quality: are the logs incomplete? Has the definition of order statuses changed?
SELECT DATE_TRUNC('week', order_date)::date AS wk,
       COUNT(*) AS orders, SUM(amount) AS rev,
       ROUND(AVG(amount)) AS aov,
       COUNT(DISTINCT customer_id) AS buyers
FROM orders WHERE status='paid'
  AND order_date >= CURRENT_DATE - INTERVAL '8 weeks'
GROUP BY 1 ORDER BY 1;
wk | orders | rev | aov | buyers -----------+--------+----------+---------+------- 2024-07-22 | 1820 | 29000000 | 159340 | 1510 2024-07-29 | 1240 | 19500000 | 157258 | 1090

In this output AOV is almost unchanged, but the number of orders and buyers has dropped — so the problem is on the traffic/acquisition side, not pricing. This analytical conclusion is what sets you apart from a “query writer”.

Classic interview problems 17

Classic problem: find duplicate emails.

▾Basic
SELECT email, COUNT(*) AS n
FROM person
GROUP BY email
HAVING COUNT(*) > 1;
email | n ---------------+--- a@example.com | 3 b@example.com | 2

The simplest question of the “group and filter on the group” pattern. If they want to see the duplicate rows themselves, not just the emails, use COUNT(*) OVER (PARTITION BY email) > 1 in a subquery, which works in a single scan.

Classic problem: employees who earn more than their manager.

▾Basic
SELECT e.name AS employee, e.salary, m.name AS manager, m.salary AS manager_salary
FROM employees e
JOIN employees m ON m.emp_id = e.manager_id
WHERE e.salary > m.salary;
employee | salary | manager | manager_salary ---------+----------+---------+--------------- Joe | 90000000 | Sam | 80000000

This is the classic self join exercise: one table used twice under different aliases. If they ask for “employees without a manager too”, switch to LEFT JOIN and move the condition.

Find each user's first trading day and how much they deposited and withdrew before it.

▾Intermediate
-- trades(trade_id, user_id, trade_time, symbol, amount)
-- transactions(txn_id, user_id, txn_time, txn_type, amount)   txn_type: deposit | withdrawal
WITH first_trade AS (
    SELECT user_id,
           MIN(trade_time) AS first_trade_at
    FROM trades
    GROUP BY user_id
)
SELECT f.user_id,
       f.first_trade_at,
       COALESCE(SUM(t.amount) FILTER (WHERE t.txn_type = 'deposit'),    0) AS deposit_before,
       COALESCE(SUM(t.amount) FILTER (WHERE t.txn_type = 'withdrawal'), 0) AS withdraw_before,
       COUNT(*) FILTER (WHERE t.txn_type = 'deposit')                       AS n_deposits,
       COALESCE(SUM(t.amount) FILTER (WHERE t.txn_type = 'deposit'),    0)
     - COALESCE(SUM(t.amount) FILTER (WHERE t.txn_type = 'withdrawal'), 0) AS net_funding,
       MIN(t.txn_time)                                                      AS first_txn_at,
       f.first_trade_at::date - MIN(t.txn_time)::date                       AS days_to_first_trade
FROM first_trade f
LEFT JOIN transactions t
       ON t.user_id  = f.user_id
      AND t.txn_time < f.first_trade_at      -- time condition inside ON, not in WHERE
GROUP BY f.user_id, f.first_trade_at
ORDER BY net_funding DESC;
user_id | first_trade_at | deposit_before | withdraw_before | net_funding | days_to_first_trade --------+---------------------+----------------+-----------------+-------------+-------------------- 7712 | 2024-03-04 10:12:00 | 85,000,000 | 10,000,000 | 75,000,000 | 12 4410 | 2024-05-19 09:40:00 | 20,000,000 | 0 | 20,000,000 | 3 9021 | 2024-06-02 11:05:00 | 0 | 0 | 0 | NULL

Four design decisions to say out loud:

  • The condition txn_time < first_trade_at is written inside ON, not in WHERE. In WHERE, users with no transactions before their first trade (like the last row) would disappear from the result entirely.
  • COALESCE puts in zero so we don't see NULL in the report; but I deliberately left days_to_first_trade as NULL, because “zero days” and “never deposited” are two completely different things.
  • The comparison is <, not <=: a transaction recorded at the exact moment of the trade does not count as “before”. If the business logic is different, ask right here.
  • If the columns are timestamps with time zones, the day boundary must be computed in the reporting time zone, not UTC.

The Postgres-specific tool for this is DISTINCT ON, which also returns the first trade with its details: SELECT DISTINCT ON (user_id) * FROM trades ORDER BY user_id, trade_time. If they also want the symbol of the first trade, this version is better.

Classic problem: three identical consecutive numbers in a log.

▾Intermediate
-- logs(id serial, num int)  — the ids are contiguous
SELECT DISTINCT l1.num AS consecutive_num
FROM logs l1
JOIN logs l2 ON l2.id = l1.id + 1 AND l2.num = l1.num
JOIN logs l3 ON l3.id = l1.id + 2 AND l3.num = l1.num;

-- A version robust to non-contiguous ids, using window functions
SELECT DISTINCT num
FROM (
    SELECT num,
           LAG(num)  OVER (ORDER BY id) AS prev_num,
           LEAD(num) OVER (ORDER BY id) AS next_num
    FROM logs
) t
WHERE num = prev_num AND num = next_num;
consecutive_num --------------- 1

Prefer the second version and say why: if a row has been deleted, the ids have gaps and the id + 1 logic fails silently. Window functions look at the actual order, not at the id values.

Classic problem: the top three earners in each department.

▾Intermediate
WITH ranked AS (
    SELECT d.dept_name,
           e.name,
           e.salary,
           DENSE_RANK() OVER (PARTITION BY e.dept_id ORDER BY e.salary DESC) AS r
    FROM employees e
    JOIN departments d ON d.dept_id = e.dept_id
)
SELECT dept_name, name, salary
FROM ranked
WHERE r <= 3
ORDER BY dept_name, salary DESC;
dept_name | name | salary ------------+--------+---------- Engineering | Maryam | 92000000 Engineering | Sara | 88000000 Engineering | Reza | 88000000 Finance | Kaveh | 71000000

Choosing DENSE_RANK is deliberate: the problem asks for “the top three salary levels”, not “three people”. That is why two people with equal salaries both come in at rank 2. If they wanted exactly three rows, ROW_NUMBER is right — ask about exactly this distinction.

Classic problem: daily trip cancellation rate, joining to users on both sides.

▾Intermediate
-- trips(id, client_id, driver_id, status, request_at)
-- users(users_id, banned, role)
SELECT t.request_at AS day,
       ROUND(
         AVG(CASE WHEN t.status LIKE 'cancelled%' THEN 1.0 ELSE 0.0 END), 2
       ) AS cancellation_rate,
       COUNT(*) AS trips
FROM trips t
JOIN users c ON c.users_id = t.client_id AND c.banned = 'No'
JOIN users d ON d.users_id = t.driver_id AND d.banned = 'No'
WHERE t.request_at BETWEEN '2013-10-01' AND '2013-10-03'
GROUP BY t.request_at
ORDER BY t.request_at;
day | cancellation_rate | trips -----------+-------------------+------ 2013-10-01 | 0.33 | 3 2013-10-02 | 0.00 | 2 2013-10-03 | 0.50 | 4

Two subtle points: both sides of the trip (rider and driver) must be unbanned, so two separate JOINs are needed; and AVG over a zero/one expression is a cleaner version of SUM(...)/COUNT(*), with no integer-division problem because it uses 1.0.

Classic problem: days when stadium attendance was above a threshold for three consecutive days.

▾Intermediate
WITH t AS (
    SELECT id, visit_date, people,
           ROW_NUMBER() OVER (ORDER BY id) AS rn
    FROM stadium
    WHERE people >= 100
),
grp AS (
    SELECT *, rn - id AS g          -- in a continuous run, this difference stays constant
    FROM t
)
SELECT id, visit_date, people
FROM grp
WHERE g IN (SELECT g FROM grp GROUP BY g HAVING COUNT(*) >= 3)
ORDER BY id;
id | visit_date | people ---+------------+------- 5 | 2017-01-05 | 145 6 | 2017-01-06 | 1455 7 | 2017-01-07 | 199 8 | 2017-01-09 | 188

This is the same “gaps and islands” pattern you saw in earlier sections. The difference between the row number and the original id forms the id of each continuous group. Once you truly understand this pattern, it solves dozens of different questions.

Compute each category's share of the total and the cumulative share (Pareto analysis).

▾Intermediate
WITH cat AS (
  SELECT p.category, SUM(oi.qty * oi.unit_price) AS revenue
  FROM order_items oi JOIN products p USING (product_id)
  GROUP BY p.category
)
SELECT category,
       revenue,
       ROUND(100.0 * revenue / SUM(revenue) OVER (), 1) AS share_pct,
       ROUND(100.0 * SUM(revenue) OVER (ORDER BY revenue DESC
              ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
             / SUM(revenue) OVER (), 1) AS cum_pct
FROM cat
ORDER BY revenue DESC;
category | revenue | share_pct | cum_pct ------------+-------------+-----------+-------- Audio | 480,000,000 | 41.2 | 41.2 Accessories | 310,000,000 | 26.6 | 67.8 Displays | 220,000,000 | 18.9 | 86.7 Appliances | 155,000,000 | 13.3 | 100.0

The key trick is SUM(...) OVER () without PARTITION BY, which puts the grand total next to every row without needing a second subquery. The cumulative column shows the first three categories make up 87 percent of revenue — the Pareto rule.

Write sessionization of events with a 30-minute inactivity gap.

▾Intermediate
WITH marked AS (
  SELECT user_id, event_time,
         CASE WHEN event_time - LAG(event_time) OVER (PARTITION BY user_id ORDER BY event_time)
                   > INTERVAL '30 minutes'
              OR LAG(event_time) OVER (PARTITION BY user_id ORDER BY event_time) IS NULL
              THEN 1 ELSE 0 END AS is_new_session
  FROM events
),
sessions AS (
  SELECT user_id, event_time,
         SUM(is_new_session) OVER (PARTITION BY user_id ORDER BY event_time
                                   ROWS UNBOUNDED PRECEDING) AS session_no
  FROM marked
)
SELECT user_id, session_no,
       MIN(event_time)                            AS started_at,
       MAX(event_time)                            AS ended_at,
       COUNT(*)                                   AS events,
       MAX(event_time) - MIN(event_time)          AS duration
FROM sessions
GROUP BY user_id, session_no
ORDER BY user_id, session_no;
user_id | session_no | started_at | events | duration --------+------------+---------------------+--------+--------- 771 | 1 | 2024-08-31 09:14:00 | 12 | 00:18:40 771 | 2 | 2024-08-31 14:02:00 | 4 | 00:05:12

It is the same “break flag, then running sum” pattern you saw in the chain problems. The 30-minute threshold is a convention; if the interviewer asks why, say it should be derived from the actual distribution of gaps between events, not from habit.

In a 365-day year, public holidays and weekends are marked in a table, and you have 10 days of leave. Which days should you take off to get the longest continuous break?

▾Advanced

Input table structure: one row per day of the year, and an is_workday column that is zero for days off (weekends, public holidays) and one for working days.

-- calendar(d date, is_workday int)   0 = day off, 1 = working day

Modeling the problem. Leave only makes sense on working days. So we are looking for the longest continuous run of days containing no more than 10 working days; those working days are exactly the days you should take off. This is the classic “longest subarray with at most K bad elements” problem.

The key trick. We take the running sum of working days. If cw is the running sum up to day j and pre is the running sum up to just before day i, the number of working days between i and j is cw(j) - pre(i). Our condition becomes pre(i) >= cw(j) - 10. Since the sum is increasing, for each j we find the smallest possible i.

WITH c AS (
    SELECT d,
           is_workday,
           ROW_NUMBER() OVER (ORDER BY d)                       AS rn,
           SUM(is_workday) OVER (ORDER BY d)                    AS cw,
           COALESCE(SUM(is_workday) OVER (ORDER BY d
                        ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING), 0) AS pre
    FROM calendar
),
win AS (                      -- for each end day, the earliest allowed start day
    SELECT j.rn        AS end_rn,
           j.d         AS end_d,
           MIN(i.rn)   AS start_rn
    FROM c j
    JOIN c i ON i.pre >= j.cw - 10
    GROUP BY j.rn, j.d
),
best AS (
    SELECT w.start_rn, w.end_rn, w.end_d,
           w.end_rn - w.start_rn + 1 AS days_off
    FROM win w
    ORDER BY days_off DESC, w.end_rn
    LIMIT 1
)
SELECT s.d                AS break_start,
       b.end_d            AS break_end,
       b.days_off         AS total_days_off,
       (SELECT COUNT(*) FROM c
         WHERE rn BETWEEN b.start_rn AND b.end_rn AND is_workday = 1) AS leave_used
FROM best b
JOIN c s ON s.rn = b.start_rn;
break_start | break_end | total_days_off | leave_used ------------+------------+----------------+----------- 2025-03-15 | 2025-04-05 | 22 | 10

So which days do I take off? The working days inside that window:

WITH c AS ( /* same CTE as above */ ),
     best AS ( /* same CTE as above */ )
SELECT c.d AS take_leave_on,
       TO_CHAR(c.d, 'Dy') AS weekday
FROM c JOIN best b ON c.rn BETWEEN b.start_rn AND b.end_rn
WHERE c.is_workday = 1
ORDER BY c.d;
take_leave_on | weekday --------------+-------- 2025-03-17 | Mon 2025-03-18 | Tue 2025-03-19 | Wed ... | ... (10 rows)

Two points to raise yourself. First, this answer gives the optimal window, but there may be several windows of the same length; pick one with a tie-breaker (for example, closest to a preferred season). Second, the JOIN above is O(n²), which is no problem at all for 365 rows, but with millions of rows you'd move to a two-pointer approach in Python or a window function with binary search.

Common trap: some people only compute “the gap between two holidays” and greedily pour all 10 days of leave into the biggest gap. That doesn't give the optimal answer, because sometimes filling two smaller gaps that sit between three holidays builds a longer chain.

Classic problem: compute the median in SQL without a built-in function, on a table with repeated values.

▾Advanced
WITH t AS (
    SELECT company, salary,
           ROW_NUMBER() OVER (PARTITION BY company ORDER BY salary, id) AS rn,
           COUNT(*)     OVER (PARTITION BY company)                     AS n
    FROM employee
)
SELECT company,
       AVG(salary) AS median_salary
FROM t
WHERE rn IN (FLOOR((n + 1) / 2.0), FLOOR((n + 2) / 2.0))
GROUP BY company;
company | median_salary --------+-------------- A | 61500.0 B | 55000.0

The condition rn IN ((n+1)/2, (n+2)/2) covers both the odd and even cases: for odd n both expressions give the same number, and for even n they return the two middle values. Adding id to ORDER BY is needed so the numbering is deterministic and reproducible when salaries are equal.

Classic problem: reshaping data for a report by swapping odd and even seats.

▾Advanced
SELECT
    CASE
      WHEN id % 2 = 1 AND id = (SELECT MAX(id) FROM seat) THEN id   -- the last unpaired odd one
      WHEN id % 2 = 1 THEN id + 1
      ELSE id - 1
    END AS id,
    student
FROM seat
ORDER BY id;
id | student → id | student ---+--------- ---+-------- 1 | Abbot 1 | Doris 2 | Doris 2 | Abbot 3 | Emerson 3 | Green 4 | Green 4 | Emerson 5 | Jeames 5 | Jeames

This question is popular because of its edge case: if the number of rows is odd, the last person has no partner and must stay put. A candidate who writes only the odd and even conditions misses exactly this case.

Classic problem: each customer's rolling three-day total from a transactions table.

▾Advanced
SELECT customer_id,
       visited_on,
       SUM(amount) OVER w AS amount_3d,
       ROUND(AVG(amount) OVER w, 2) AS average_3d
FROM customer_spend
WINDOW w AS (
    PARTITION BY customer_id
    ORDER BY visited_on
    RANGE BETWEEN INTERVAL '2 days' PRECEDING AND CURRENT ROW
)
ORDER BY customer_id, visited_on;
customer_id | visited_on | amount_3d | average_3d ------------+------------+-----------+----------- 101 | 2024-08-01 | 250,000 | 250000.00 101 | 2024-08-02 | 580,000 | 290000.00 101 | 2024-08-04 | 470,000 | 235000.00

Two senior-level points: the WINDOW clause lets you write the window definition once and use it several times; and choosing RANGE with a time interval instead of ROWS is deliberate, because the third row has no August 3 and we want “three calendar days”, not “three rows”.

Classic problem: find users who were active for three consecutive periods and then churned.

▾Advanced
WITH monthly AS (
    SELECT user_id,
           DATE_TRUNC('month', event_time)::date AS mon
    FROM events
    GROUP BY 1, 2
),
flagged AS (
    SELECT user_id, mon,
           LAG(mon, 1)  OVER (PARTITION BY user_id ORDER BY mon) AS m1,
           LAG(mon, 2)  OVER (PARTITION BY user_id ORDER BY mon) AS m2,
           LEAD(mon, 1) OVER (PARTITION BY user_id ORDER BY mon) AS nxt
    FROM monthly
)
SELECT user_id,
       m2   AS streak_start,
       mon  AS streak_end
FROM flagged
WHERE m1 = mon - INTERVAL '1 month'
  AND m2 = mon - INTERVAL '2 months'
  AND (nxt IS NULL OR nxt > mon + INTERVAL '1 month')
ORDER BY user_id;
user_id | streak_start | streak_end --------+--------------+----------- 771 | 2024-03-01 | 2024-05-01 902 | 2024-01-01 | 2024-03-01

Combining LAG to check past continuity with LEAD to prove the break is a very useful pattern in churn analysis. Note that nxt IS NULL must be in the condition too, otherwise users whose data ends in that same month are lost.

Classic problem: the length of the longest rising price streak.

▾Advanced
WITH p AS (
    SELECT trade_date, close_price,
           ROW_NUMBER() OVER (ORDER BY trade_date) AS rn,
           CASE WHEN close_price > LAG(close_price) OVER (ORDER BY trade_date)
                THEN 0 ELSE 1 END AS is_break
    FROM prices
),
grp AS (
    SELECT *, SUM(is_break) OVER (ORDER BY rn ROWS UNBOUNDED PRECEDING) AS g
    FROM p
)
SELECT MIN(trade_date) AS run_start,
       MAX(trade_date) AS run_end,
       COUNT(*)        AS up_days
FROM grp
GROUP BY g
ORDER BY up_days DESC
LIMIT 3;
run_start | run_end | up_days -----------+------------+-------- 2024-05-06 | 2024-05-14 | 7 2024-02-19 | 2024-02-23 | 5

The “break flag, then running sum over the flag” pattern builds a group id. You can use the same technique for sessionizing events, finding service outage periods and any “continuous run” problem — worth memorizing.

Without PIVOT, turn a table from long to wide.

▾Advanced
-- Long to wide: months become columns
SELECT city,
       SUM(amount) FILTER (WHERE EXTRACT(MONTH FROM order_date) = 1) AS jan,
       SUM(amount) FILTER (WHERE EXTRACT(MONTH FROM order_date) = 2) AS feb,
       SUM(amount) FILTER (WHERE EXTRACT(MONTH FROM order_date) = 3) AS mar,
       SUM(amount)                                                    AS total
FROM orders o JOIN customers c USING (customer_id)
WHERE status = 'paid'
GROUP BY city
ORDER BY total DESC;

-- Wide to long
SELECT city, month_name, amount
FROM wide_sales
CROSS JOIN LATERAL (VALUES ('jan', jan), ('feb', feb), ('mar', mar))
           AS t(month_name, amount)
WHERE amount IS NOT NULL;
city | jan | feb | mar | total --------+------------+------------+------------+----------- Tehran | 50,000,000 | 62,000,000 | 71,000,000 | 183,000,000 Mashhad | 18,000,000 | 21,000,000 | 19,000,000 | 58,000,000

Standard SQL can't decide the number of output columns dynamically; the columns must be known when the query is written. If the number of columns varies, the right answer is to return the data long and leave the pivoting to the reporting layer.

Market basket: the most frequent pairs of products bought together.

▾Advanced
WITH pairs AS (
  SELECT a.product_id AS p1, b.product_id AS p2, COUNT(*) AS together
  FROM order_items a
  JOIN order_items b ON b.order_id = a.order_id AND a.product_id < b.product_id
  GROUP BY 1, 2
),
freq AS (
  SELECT product_id, COUNT(DISTINCT order_id) AS n
  FROM order_items GROUP BY 1
),
total AS (SELECT COUNT(DISTINCT order_id) AS n FROM order_items)
SELECT x.name AS product_1, y.name AS product_2, pr.together,
       ROUND(pr.together::numeric * t.n / (f1.n * f2.n), 2) AS lift
FROM pairs pr
JOIN freq f1 ON f1.product_id = pr.p1
JOIN freq f2 ON f2.product_id = pr.p2
CROSS JOIN total t
JOIN products x ON x.product_id = pr.p1
JOIN products y ON y.product_id = pr.p2
WHERE pr.together >= 30
ORDER BY lift DESC
LIMIT 5;
product_1 | product_2 | together | lift -----------+---------------+----------+----- Headphones | Carrying case | 412 | 6.84 Keyboard | Mouse | 1,240 | 4.11

The condition a.product_id < b.product_id does two things: it stops the same pair being counted in both directions, and it doesn't pair a product with itself. The lift measure matters too — a high count alone isn't enough, because two best-sellers naturally show up together a lot.

Ten most frequent interview problems 10

1. Find duplicates

▾Basic
-- a) Only the duplicated values and their counts
SELECT email, COUNT(*) AS cnt
FROM customers
GROUP BY email
HAVING COUNT(*) > 1
ORDER BY cnt DESC;

-- b) The duplicate rows themselves, in one scan
SELECT *
FROM (
  SELECT c.*,
         COUNT(*) OVER (PARTITION BY email) AS dup_cnt,
         ROW_NUMBER() OVER (PARTITION BY email ORDER BY created_at) AS rn
  FROM customers c
) t
WHERE dup_cnt > 1
ORDER BY email, rn;

-- c) Duplicates by a composite key
SELECT first_name, last_name, birth_date, COUNT(*) AS cnt
FROM persons
GROUP BY 1, 2, 3
HAVING COUNT(*) > 1;
email | cnt ---------------+---- a@example.com | 3 b@example.com | 2

Prefer version “b” when you want to see the rows themselves: with GROUP BY you have to JOIN back to the original table, but a window function does both in a single read.

Before anything else, ask what “duplicate” means: identical in every column, or identical in the business key? These give completely different answers, and the interviewer is waiting for exactly this question.

2. Nth highest salary

▾Intermediate
-- a) Simplest: OFFSET
SELECT DISTINCT salary
FROM employees
ORDER BY salary DESC
OFFSET 2 LIMIT 1;                 -- third highest

-- b) With a window function, works for any N
WITH r AS (
  SELECT DISTINCT salary,
         DENSE_RANK() OVER (ORDER BY salary DESC) AS rnk
  FROM employees
)
SELECT salary FROM r WHERE rnk = 3;

-- c) A function version that returns NULL if it doesn't exist
CREATE OR REPLACE FUNCTION nth_salary(n int) RETURNS numeric AS $$
  SELECT salary FROM (
    SELECT DISTINCT salary, DENSE_RANK() OVER (ORDER BY salary DESC) rnk
    FROM employees) t
  WHERE rnk = n;
$$ LANGUAGE sql;
salary --------- 42000000

Three things the interviewer checks: using DISTINCT or DENSE_RANK for equal salaries, the right behavior when N exceeds the number of existing levels (it should return NULL, not an error), and knowing that DENSE_RANK differs from RANK here.

3. Top N per group

▾Intermediate
-- The three best-selling products in each category
WITH ranked AS (
  SELECT p.category,
         p.product_name,
         SUM(oi.qty * oi.unit_price) AS revenue,
         ROW_NUMBER() OVER (PARTITION BY p.category
                            ORDER BY SUM(oi.qty * oi.unit_price) DESC,
                                     p.product_id) AS rn
  FROM order_items oi
  JOIN products p USING (product_id)
  GROUP BY p.category, p.product_name, p.product_id
)
SELECT category, product_name, revenue
FROM ranked
WHERE rn <= 3
ORDER BY category, rn;
category | product_name | revenue ------------+--------------+----------- Audio | Headphones | 480,000,000 Audio | Speaker | 190,000,000 Accessories | Keyboard | 310,000,000
-- The compact Postgres version when you only want the top one
SELECT DISTINCT ON (category) category, product_name, revenue
FROM product_revenue
ORDER BY category, revenue DESC;

Explain the choice between ROW_NUMBER, RANK and DENSE_RANK out loud: ROW_NUMBER if you want exactly three rows, DENSE_RANK if you want “the top three levels”, and RANK if ties must both appear. Add a secondary tie-breaker too so the result is deterministic.

4. Running total

▾Intermediate
SELECT order_date::date AS d,
       SUM(amount)                                    AS daily,
       SUM(SUM(amount)) OVER (ORDER BY order_date::date
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS cumulative,
       -- Cumulative within each month, with a reset
       SUM(SUM(amount)) OVER (PARTITION BY DATE_TRUNC('month', order_date)
            ORDER BY order_date::date
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS mtd
FROM orders
WHERE status = 'paid'
GROUP BY 1, DATE_TRUNC('month', order_date)
ORDER BY 1;
d | daily | cumulative | mtd ------------+---------+------------+--------- 2024-08-30 | 620,000 | 41,180,000 | 8,940,000 2024-08-31 | 700,000 | 41,880,000 | 9,640,000 2024-09-01 | 510,000 | 42,390,000 | 510,000
Writing ROWS explicitly is required. When you write ORDER BY, the default frame is RANGE, which takes rows with equal values together; if you have several rows with the same date, the running total jumps.

The combination SUM(SUM(...)) OVER (...) looks odd at first: the inner aggregate runs per group and the window function runs over the grouped results. Explaining this execution order scores well.

5. Moving average

▾Intermediate
WITH cal AS (                       -- build a complete calendar first
  SELECT g::date AS d
  FROM generate_series(DATE '2024-08-01', DATE '2024-08-31', '1 day') g
),
daily AS (
  SELECT c.d, COALESCE(SUM(o.amount), 0) AS rev
  FROM cal c
  LEFT JOIN orders o ON o.order_date::date = c.d AND o.status = 'paid'
  GROUP BY c.d
)
SELECT d, rev,
       ROUND(AVG(rev) OVER (ORDER BY d ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)) AS ma7,
       ROUND(AVG(rev) OVER (ORDER BY d ROWS BETWEEN 29 PRECEDING AND CURRENT ROW)) AS ma30,
       COUNT(*)  OVER (ORDER BY d ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS window_days
FROM daily
ORDER BY d;
d | rev | ma7 | ma30 | window_days ------------+---------+---------+---------+------------ 2024-08-05 | 480,000 | 512,000 | 512,000 | 5 2024-08-06 | 620,000 | 540,000 | 540,000 | 6 2024-08-07 | 700,000 | 563,000 | 563,000 | 7

Two vital points: build the calendar with generate_series so days without sales become zero (otherwise the average is over “7 days that have data”, not 7 calendar days), and the window_days column, which shows that at the start of the range the window isn't full yet and those values aren't reliable.

6. Latest record per group

▾Intermediate
-- a) Portable to every database
SELECT customer_id, order_id, order_date, amount
FROM (
  SELECT o.*,
         ROW_NUMBER() OVER (PARTITION BY customer_id
                            ORDER BY order_date DESC, order_id DESC) AS rn
  FROM orders o
) t
WHERE rn = 1;

-- b) The fast Postgres version
SELECT DISTINCT ON (customer_id) *
FROM orders
ORDER BY customer_id, order_date DESC, order_id DESC;

-- c) Without window functions (older databases)
SELECT o.*
FROM orders o
JOIN (SELECT customer_id, MAX(order_date) AS mx
      FROM orders GROUP BY customer_id) m
  ON m.customer_id = o.customer_id AND m.mx = o.order_date;
customer_id | order_id | order_date | amount ------------+----------+------------+--------- 101 | 9310 | 2024-07-21 | 890,000 102 | 9288 | 2024-07-19 | 1,250,000

Always add the secondary tie-breaker (order_id DESC) so that if two orders fall on the same day, the result is deterministic and reproducible. Approach “c” has exactly this problem: if two orders share a date, both come back.

8. Remove duplicates

▾Intermediate
-- a) Always SELECT first to see what will be deleted
WITH dup AS (
  SELECT customer_id,
         ROW_NUMBER() OVER (PARTITION BY email
                            ORDER BY created_at DESC, customer_id DESC) AS rn
  FROM customers
)
SELECT COUNT(*) FROM dup WHERE rn > 1;

-- b) Delete, keeping the newest
WITH dup AS (
  SELECT customer_id,
         ROW_NUMBER() OVER (PARTITION BY email
                            ORDER BY created_at DESC, customer_id DESC) AS rn
  FROM customers
)
DELETE FROM customers
WHERE customer_id IN (SELECT customer_id FROM dup WHERE rn > 1);

-- c) Rebuild the table: faster than DELETE on large volumes
CREATE TABLE customers_clean AS
SELECT DISTINCT ON (email) * FROM customers ORDER BY email, created_at DESC;

-- d) Prevent duplicates in the future
CREATE UNIQUE INDEX CONCURRENTLY ux_customers_email ON customers (lower(email));
DELETE 37

Part “d” is the most important part of the answer. Removing duplicates without adding a unique constraint means you'll be doing the same job again next week. Saying this shows the difference between an analyst and a data engineer.

9. Missing records

▾Intermediate
-- a) Unmatched rows: customers without orders
SELECT c.customer_id, c.name
FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id);

-- b) Days without sales
SELECT g::date AS missing_day
FROM generate_series(DATE '2024-08-01', DATE '2024-08-31', '1 day') g
LEFT JOIN orders o ON o.order_date::date = g::date
WHERE o.order_id IS NULL;

-- c) Gaps in an id sequence
SELECT prev_id + 1 AS gap_start, id - 1 AS gap_end, id - prev_id - 1 AS missing_cnt
FROM (SELECT id, LAG(id) OVER (ORDER BY id) AS prev_id FROM invoices) t
WHERE id - prev_id > 1;

-- d) Two-way differences between two sources
SELECT COALESCE(a.id, b.id) AS id,
       CASE WHEN a.id IS NULL THEN 'target only' ELSE 'source only' END AS side
FROM source_t a
FULL JOIN target_t b USING (id)
WHERE a.id IS NULL OR b.id IS NULL;
gap_start | gap_end | missing_cnt ----------+---------+------------ 10345 | 10347 | 3
Don't use NOT IN (subquery): if the subquery returns even one NULL, the whole result is empty and you silently get a wrong answer. Always use NOT EXISTS or LEFT JOIN ... IS NULL.

7. Consecutive dates and the longest streak

▾Advanced
WITH d AS (
  SELECT DISTINCT user_id, event_time::date AS d
  FROM events
),
g AS (
  SELECT user_id, d,
         d - (ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY d))::int AS grp
  FROM d
),
streaks AS (
  SELECT user_id, grp,
         MIN(d) AS start_d, MAX(d) AS end_d, COUNT(*) AS streak
  FROM g
  GROUP BY user_id, grp
)
SELECT user_id, start_d, end_d, streak
FROM streaks
WHERE streak >= 3
ORDER BY streak DESC, user_id;
user_id | start_d | end_d | streak --------+------------+------------+------- 771 | 2024-08-04 | 2024-08-10 | 7 902 | 2024-08-12 | 2024-08-14 | 3
-- Each user's longest current streak
SELECT DISTINCT ON (user_id) user_id, streak, start_d, end_d
FROM streaks
ORDER BY user_id, streak DESC;

This pattern is known as “gaps and islands”. The key is that in a continuous run, “date minus row number” stays constant, and that constant becomes the streak id. If you skip the initial DISTINCT and a user has two events on one day, the numbering breaks.

10. Employee hierarchy

▾Advanced
-- employees(emp_id, name, manager_id, dept, salary)
WITH RECURSIVE org AS (
    -- Anchor: top managers with no supervisor
    SELECT emp_id, name, manager_id,
           1                        AS lvl,
           name::text               AS path,
           ARRAY[emp_id]            AS id_path
    FROM employees
    WHERE manager_id IS NULL

    UNION ALL

    -- Recursive: one level down
    SELECT e.emp_id, e.name, e.manager_id,
           o.lvl + 1,
           o.path || ' › ' || e.name,
           o.id_path || e.emp_id
    FROM employees e
    JOIN org o ON e.manager_id = o.emp_id
    WHERE NOT e.emp_id = ANY(o.id_path)     -- protection against cycles
)
SELECT lvl,
       repeat('   ', lvl - 1) || name AS tree,
       path
FROM org
ORDER BY path;
lvl | tree | path ----+--------------------+--------------------------------- 1 | CEO | CEO 2 | CTO | CEO › CTO 3 | Data analyst | CEO › CTO › Data analyst 2 | CFO | CEO › CFO
-- Derived uses: total number of reports under each manager
WITH RECURSIVE sub AS (
  SELECT emp_id AS root, emp_id FROM employees
  UNION ALL
  SELECT s.root, e.emp_id FROM employees e JOIN sub s ON e.manager_id = s.emp_id
)
SELECT root AS manager_id, COUNT(*) - 1 AS total_reports
FROM sub GROUP BY root HAVING COUNT(*) > 1 ORDER BY total_reports DESC;

Three things make your answer professional: keeping a path for tree ordering, an id_path array to prevent an infinite loop if the data has a cycle, and knowing that the same pattern works exactly the same way for product category trees and BOM structures.

Python

The Python section is organized in three layers: the core language, the pandas and NumPy libraries that matter most for working with data, and supporting libraries. Coding exercises and classic algorithm problems come at the end of the section.

Python cheat sheet — core language

The things that tend to slip your mind right when you need them.PYTHON
[x*2 for x in xs if x>0]
List comprehension with a filter
{k: v for k, v in pairs}
dict comprehension
(x*2 for x in xs)
Generator — lazy and memory-light
enumerate(xs, start=1)
Index + value
zip(a, b), zip(*rows)
Pairing / transposing
sorted(xs, key=lambda r: (-r[1], r[0]))
Multi-key sort, descending via negation
from collections import Counter, defaultdict, deque, namedtuple
Handy counting and queue tools
Counter(xs).most_common(3)
Top three most common
itertools.groupby(sorted(xs, key=f), f)
Grouping — the input must be sorted
functools.lru_cache(maxsize=None)
Memoization
with open(p, encoding='utf-8') as f:
Safe resource management
try / except E as e / else / finally
Error handling
def f(*args, **kwargs)
Variable arguments
f"{x:,.2f}" · f"{x=}"
Number formatting and quick debugging
datetime.strptime(s, '%Y-%m-%d')
String to date
re.findall(r'\d+', s)
Regular expression
json.loads / json.dumps(o, ensure_ascii=False)
JSON with non-ASCII text preserved
pathlib.Path('d').glob('*.csv')
Working with file paths
@dataclass
Lightweight data class
logging.getLogger(__name__)
Logging instead of print in production

pandas cheat sheet

The most important part for a data role — read this table twice.PYTHON
pd.read_csv(p, parse_dates=['d'], dtype={'id':'int32'}, chunksize=100_000)
Efficient and chunked reading
df.info() · df.describe() · df.head()
First look at the data
df.shape · df.dtypes · df.nunique()
Shape, types and unique values
df.isna().sum()
Count of missing values per column
df.dropna(subset=['a']) · df.fillna(0)
Drop / fill NaN
df.drop_duplicates(subset=['id'], keep='last')
Drop duplicates
df.loc[mask, 'col'] · df.iloc[0:5, 2]
Label-based / position-based selection
df.query("amount > 100 and city=='Tehran'")
Readable filter
df.assign(rev=lambda d: d.qty*d.price)
New column without changing the original
df.groupby('k').agg( rev=('amount','sum'), n=('id','count'))
named aggregation
df.groupby('k')['x'].transform('mean')
Equivalent of a window function
df.merge(o, on='id', how='left', indicator=True, validate='1:m')
Join with relationship validation
pd.concat([a,b], axis=0, ignore_index=True)
Concatenation
df.pivot_table(index='city', columns='q', values='amt', aggfunc='sum')
Pivot table
df.melt(id_vars='id', var_name='k')
Wide to long
df.sort_values(['a','b'], ascending=[True, False])
Multi-column sort
df.set_index('d').resample('M').sum()
Time-based aggregation
df['x'].rolling(7).mean() df['x'].shift(1) · .diff() · .pct_change()
Rolling window and period comparison
df['c'] = df['c'].astype('category')
Big memory reduction
pd.cut / pd.qcut
Binning by range / by quantile

Core Python 27

P01

Difference between list, tuple and set?

▾Basic
listtupleset
MutableYesNoYes
OrderedYesYesNo
DuplicatesAllowedAllowedRemoved
Dict keyNoYesNo
in lookupO(n)O(n)O(1)
ids = [3, 1, 3, 2]
print(set(ids), len(set(ids)))
print(3 in set(ids))   # O(1) — a huge difference on large data
{1, 2, 3} 3 True

If they ask “why is your program slow?”, a frequent answer is exactly this: an in lookup on a list instead of a set.

P02

Difference between is and ==?

▾Basic
a = [1, 2]; b = [1, 2]; c = a
print(a == b, a is b, a is c)
True False True

== is value equality, is is being the same object in memory. For None, always write x is None.

P03

What is the difference between a shallow and a deep copy?

▾Basic
import copy
a = [[1, 2], [3, 4]]
s = copy.copy(a);      s[0][0] = 99
d = copy.deepcopy(a);  d[1][0] = 77
print(a)
[[99, 2], [3, 4]]

A shallow copy only separates the outer layer; nested objects stay shared. The pandas equivalent is df.copy(deep=True).

P04

What problem does a mutable default value cause?

▾Basic
def add(x, box=[]):     # wrong
    box.append(x); return box
print(add(1)); print(add(2))

def add_ok(x, box=None):  # right
    box = [] if box is None else box
    box.append(x); return box
print(add_ok(1)); print(add_ok(2))
[1] [1, 2] <-- the list was shared between calls [1] [2]

The default value is created only once, when the function is defined. This is a classic screening question.

P05

List comprehension versus a for loop?

▾Basic
nums = range(1, 11)
squares_even = [n**2 for n in nums if n % 2 == 0]
labels = ['even' if n % 2 == 0 else 'odd' for n in nums]
print(squares_even); print(labels[:4])
[4, 16, 36, 64, 100] ['odd', 'even', 'odd', 'even']

Careful: a filtering if comes after the loop, but a conditional if/else comes before it. Don't mix up their positions in an interview.

P06

Are arguments in Python passed by value or by reference?

▾Basic
def f(n, lst):
    n = n + 1          # int is immutable — a local copy
    lst.append(4)      # list is mutable — the same object
x, y = 1, [1, 2, 3]
f(x, y); print(x, y)
1 [1, 2, 3, 4]

The correct answer: “pass by object reference”. Immutable objects behave like values and mutable ones behave like references.

What are the basic data types in Python?

▾Basic
n   = 42                # int
f   = 3.14              # float
s   = "data"            # str
b   = True              # bool
non = None              # NoneType
lst = [1, 2, 3]         # list   — mutable, ordered
tup = (1, 2)            # tuple  — immutable
st  = {1, 2, 3}         # set    — unique, unordered
d   = {"city": "Tehran"} # dict  — keys and values

for x in (n, f, s, b, non, lst, tup, st, d):
    print(type(x).__name__, end=" ")
int float str bool NoneType list tuple set dict

Python infers a variable's type itself, but types still matter: "5" + 5 raises an error because a string and a number can't be added. When working with data, most errors are of this kind.

Show how to work with strings.

▾Basic
s = "  Data Analyst  "
print(s.strip())              # remove leading and trailing spaces
print(s.strip().lower())      # lowercase
print(s.strip().split(" "))   # split into a list
print("-".join(["a", "b"]))   # join with a separator
print(s.replace("Data", "Info").strip())
print(len(s.strip()))
name, city = "Maryam", "Tehran"
print(f"{name} from {city} — amount: {1250000:,}")
Data Analyst data analyst ['Data', 'Analyst'] a-b Info Analyst 12 Maryam from Tehran — amount: 1,250,000

f-string formatting is the most readable approach. {x:,} adds a thousands separator, {x:.2f} two decimal places and {x:.1%} a percentage. Memorize these three; you'll need them constantly when writing reports.

Write a basic loop and condition with a data example.

▾Basic
orders = [
    {"id": 1, "amount": 1_250_000, "status": "paid"},
    {"id": 2, "amount":   320_000, "status": "cancelled"},
    {"id": 3, "amount":   480_000, "status": "paid"},
]

total, count = 0, 0
for o in orders:
    if o["status"] != "paid":
        continue                   # skip this row
    total += o["amount"]
    count += 1

print(f"Paid orders: {count}")
print(f"Total: {total:,}  |  Average: {total/count:,.0f}")
Paid orders: 2 Total: 1,730,000 | Average: 865,000

Know the two loop-control keywords: continue jumps to the next iteration and break stops the whole loop. In loops over large files, a break after finding the result can cut the run time several times over.

How do you build and iterate over a dictionary?

▾Basic
rev = {"Tehran": 183_000_000, "Mashhad": 58_000_000}

rev["Isfahan"] = 41_000_000          # add
rev["Tehran"] += 5_000_000           # update
print(rev.get("Shiraz", 0))          # default value instead of an error

for city, amount in rev.items():
    print(f"{city:8s} {amount:>12,}")

top = max(rev, key=rev.get)
print("Top:", top)
0 Tehran 188,000,000 Mashhad 58,000,000 Isfahan 41,000,000 Top: Tehran

The difference between rev["x"] and rev.get("x") matters: the first raises KeyError and stops the program if the key is missing; the second returns None or whatever default you give it.

Write a function and add a default value and documentation.

▾Basic
def growth_rate(current, previous, as_percent=True):
    """Computes the growth rate between two periods.

    Returns None if the previous period is zero or empty
    so the unknown case can be detected instead of raising an error.
    """
    if not previous:
        return None
    rate = (current - previous) / previous
    return rate * 100 if as_percent else rate

print(growth_rate(150, 120))
print(growth_rate(150, 120, as_percent=False))
print(growth_rate(150, 0))
25.0 0.25 None

Two good habits that score points in code review: handling the edge case explicitly (a zero denominator) and explaining why the decision was made, not just what the function does.

Read a CSV file with the standard library.

▾Basic
import csv
from collections import defaultdict

totals = defaultdict(float)
with open("orders.csv", encoding="utf-8", newline="") as f:
    for row in csv.DictReader(f):
        if row["status"] == "paid":
            totals[row["city"]] += float(row["amount"])

for city, amt in sorted(totals.items(), key=lambda kv: -kv[1]):
    print(f"{city:8s} {amt:>15,.0f}")
Tehran 183,000,000 Mashhad 58,000,000 Isfahan 41,000,000

For small to medium files pandas is more convenient; but csv.DictReader doesn't load the whole file into memory, which makes it the better choice for multi-gigabyte files or lightweight scripts without dependencies.

Show slicing of lists and strings.

▾Basic
xs = [10, 20, 30, 40, 50]
print(xs[0], xs[-1])      # first and last
print(xs[1:4])            # from index 1 up to (not including) 4
print(xs[:3], xs[3:])     # first three, the rest
print(xs[::2])            # every other element
print(xs[::-1])           # reversed

s = "1403-05-14"
print(s[:4], s[5:7], s[-2:])
10 50 [20, 30, 40] [10, 20, 30] [40, 50] [10, 30, 50] [50, 40, 30, 20, 10] 1403 05 14

The key rule: the lower bound is included and the upper bound is not. That is why xs[:3] + xs[3:] rebuilds exactly the original list, with no overlap and no missing element.

P07

What is a generator, and where do you use one instead of a list?

▾Intermediate
import sys
lst = [i*i for i in range(1_000_000)]
gen = (i*i for i in range(1_000_000))
print(sys.getsizeof(lst), sys.getsizeof(gen))

def read_big(path):           # process a large file without loading it all
    with open(path, encoding='utf-8') as f:
        for line in f:
            yield line.rstrip('\n')
8448728 200

A generator is lazy: it produces values on demand and uses no memory for them, but it can be iterated only once and has no len(). It is the natural choice for multi-gigabyte files or a Kafka stream.

P08

Write a decorator that measures a function's run time.

▾Intermediate
import time, functools

def timed(fn):
    @functools.wraps(fn)              # keep the original function's name and docstring
    def wrapper(*args, **kwargs):
        t0 = time.perf_counter()
        result = fn(*args, **kwargs)
        print(f"{fn.__name__} took {time.perf_counter()-t0:.3f}s")
        return result
    return wrapper

@timed
def slow_sum(n):
    return sum(i*i for i in range(n))

slow_sum(3_000_000)
slow_sum took 0.214s

Without functools.wraps, the function's __name__ changes to “wrapper”, which breaks debugging and logging. Mentioning this earns points.

P09

What do *args and **kwargs do?

▾Intermediate
def report(title, *metrics, **options):
    print(title, metrics, options)

report("sales", "rev", "aov", currency="IRR", round_to=0)
sales ('rev', 'aov') {'currency': 'IRR', 'round_to': 0}

In a call, the star does the reverse operation (unpacking): f(*lst) and f(**dct).

P10

Build a context manager.

▾Intermediate
from contextlib import contextmanager
import time

@contextmanager
def stage(name):
    t0 = time.perf_counter()
    try:
        yield
    finally:
        print(f"[{name}] {time.perf_counter()-t0:.2f}s")

with stage("load"):
    time.sleep(0.5)
[load] 0.50s

The main benefit: the finally block runs even when an exception occurs — vital for closing a database connection or releasing a lock.

P11

How do you write error handling in an ETL script?

▾Intermediate
import logging
logging.basicConfig(level=logging.INFO,
                    format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)

def safe_div(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        log.warning("division by zero, returning None")
        return None
    except TypeError as e:
        log.error("bad types: %s", e)
        raise                      # don't hide unexpected errors
    finally:
        log.debug("safe_div finished")

print(safe_div(10, 2), safe_div(1, 0))
2024-08-30 10:02:11 WARNING division by zero, returning None 5.0 None

Make two points: never write a bare except:, and raise again any error you can't handle meaningfully.

P12

What is the GIL and how does it affect parallelism?

▾Intermediate

The Global Interpreter Lock lets only one thread execute Python bytecode at any moment.

  • I/O-bound work (API calls, reading files, database queries) → threading or asyncio helps, because the GIL is released while waiting.
  • CPU-bound work → multiprocessing, or C libraries such as NumPy that release the GIL.
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
with ThreadPoolExecutor(max_workers=8) as ex:      # I/O
    results = list(ex.map(fetch_url, urls))
with ProcessPoolExecutor(max_workers=4) as ex:     # CPU
    results = list(ex.map(heavy_calc, chunks))
P13

Difference between @staticmethod, @classmethod and an instance method?

▾Intermediate
class Pipeline:
    version = "1.0"
    def __init__(self, name): self.name = name
    def run(self):                    return f"{self.name} running"
    @classmethod
    def from_config(cls, cfg):        return cls(cfg["name"])
    @staticmethod
    def validate(path):               return path.endswith(".csv")

p = Pipeline.from_config({"name": "daily"})
print(p.run(), Pipeline.validate("a.csv"))
daily running True

classmethod is for alternative constructors (factories), and staticmethod is for helper functions related to the class but independent of its state.

P14

What becomes faster with collections?

▾Intermediate
from collections import Counter, defaultdict, deque

logs = ["view", "click", "view", "buy", "view", "click"]
print(Counter(logs).most_common(2))

by_user = defaultdict(list)
for uid, ev in [(1,"view"), (2,"buy"), (1,"click")]:
    by_user[uid].append(ev)          # no need to check whether the key exists
print(dict(by_user))

window = deque(maxlen=3)             # fixed-size queue
for x in [1,2,3,4,5]: window.append(x)
print(list(window))
[('view', 3), ('click', 2)] {1: ['view', 'click'], 2: ['buy']} [3, 4, 5]
P15

Extract Iranian mobile numbers from text with re.

▾Intermediate
import re
text = "Call: 09121234567 or 0913-765-4321, landline 02188776655"
pattern = r"09\d{2}[-\s]?\d{3}[-\s]?\d{4}"
nums = [re.sub(r"[-\s]", "", m) for m in re.findall(pattern, text)]
print(nums)
['09121234567', '09137654321']

Common patterns: \d a digit, \w a word character, + one or more, ? optional, () a capturing group, (?:) a non-capturing group. Use re.compile for frequently used patterns so they are cached.

P16

Difference between an iterator and an iterable? Build one.

▾Advanced
class Batcher:
    """iterable — returns a fresh iter every time"""
    def __init__(self, data, size): self.data, self.size = data, size
    def __iter__(self):
        for i in range(0, len(self.data), self.size):
            yield self.data[i:i+self.size]

for batch in Batcher(list(range(7)), 3):
    print(batch)
[0, 1, 2] [3, 4, 5] [6]

An iterable is something with __iter__; an iterator also has __next__ and ends with StopIteration. The batching pattern is exactly what you need to send data to a database or Kafka in batches.

P17

Where is asyncio useful? Write an example.

▾Advanced
import asyncio, aiohttp

async def fetch(session, url):
    async with session.get(url) as r:
        return url, r.status

async def main(urls):
    async with aiohttp.ClientSession() as s:
        return await asyncio.gather(*(fetch(s, u) for u in urls))

results = asyncio.run(main(["https://a.com", "https://b.com"]))
print(results)
[('https://a.com', 200), ('https://b.com', 200)]

asyncio is concurrency, not parallelism: it only helps when you're waiting on I/O. It gives no benefit for heavy CPU computation.

P18

How do you profile and optimize slow code?

▾Advanced
import cProfile, pstats
cProfile.run("main()", "prof.out")
pstats.Stats("prof.out").sort_stats("cumtime").print_stats(10)

# Line by line:  pip install line_profiler  →  kernprof -l -v script.py
# For memory:    pip install memory_profiler
ncalls tottime cumtime filename:lineno(function) 1 0.002 4.310 etl.py:40(main) 1200 3.980 3.980 etl.py:12(row_by_row_insert)

The order of optimization: measure first, then the algorithm (better big-O), then vectorization with NumPy/pandas, then batching I/O, and finally parallelism.

A good line for the interview: “I measure first, then optimize — guesses about the bottleneck usually turn out wrong.”

P19

What does testing a data transformation function look like?

▾Advanced
# transform.py
def clean_amount(x):
    if x is None or x == "": return 0.0
    return float(str(x).replace(",", "").strip())

# test_transform.py
import pytest
from transform import clean_amount

@pytest.mark.parametrize("raw,expected", [
    ("1,200", 1200.0), (" 45 ", 45.0), (None, 0.0), ("", 0.0),
])
def test_clean_amount(raw, expected):
    assert clean_amount(raw) == expected

def test_invalid():
    with pytest.raises(ValueError):
        clean_amount("abc")
$ pytest -q 5 passed in 0.03s

If you say “for data pipelines I write edge-case tests: NaN, empty strings, duplicate data, out-of-range dates”, you score well.

P20

How do you handle virtual environments and dependency management?

▾Advanced
python -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate
pip install -r requirements.txt
pip freeze > requirements.txt      # lock the versions

# More modern: poetry / uv / pip-tools
uv venv && uv pip install pandas

Tip: pin versions in requirements.txt (pandas==2.2.2) so the build is reproducible. For production we usually build a Docker image too, so the Python version stays fixed as well.

pandas 18

D01

When you receive an unfamiliar CSV, what are your first steps?

▾Basic
import pandas as pd
df = pd.read_csv("orders.csv", parse_dates=["order_date"])
print(df.shape)
print(df.dtypes)
print(df.isna().sum())
print(df.describe(include="all").T.head())
print(df.duplicated().sum())
(50000, 6) order_id int64 customer_id int64 order_date datetime64[ns] amount float64 status object dtype: object order_id 0 amount 213 status 0 dtype: int64 duplicated rows: 87

Memorize these four lines; almost every data interview starts with “take this file and see what's going on”.

D02

Difference between loc and iloc?

▾Basic
df.loc[3, "amount"]            # by index label and column name
df.iloc[3, 2]                  # by numeric position
df.loc[df.amount > 1_000_000, ["order_id", "amount"]]
df.loc[df.status.eq("paid"), "amount"] *= 1.09   # safe assignment

The SettingWithCopyWarning appears when you assign to the result of a chained slice (df[df.a>1]["b"] = 5). The right way is always a single .loc.

D03

Write a groupby with several aggregate functions.

▾Basic
g = (df[df.status == "paid"]
     .groupby("city", as_index=False)
     .agg(revenue=("amount", "sum"),
          orders=("order_id", "count"),
          aov=("amount", "mean"),
          buyers=("customer_id", "nunique"))
     .sort_values("revenue", ascending=False))
print(g.head())
city revenue orders aov buyers 0 Tehran 183000000 1120 163392.8 890 1 Mashhad 58000000 402 144278.6 311 2 Isfahan 41000000 295 138983.0 240

The named aggregation form (name=(column, function)) is more readable and doesn't create multi-level columns. Always write it this way.

Build a DataFrame from scratch and add a column.

▾Basic
import pandas as pd

df = pd.DataFrame({
    "order_id": [9001, 9002, 9003],
    "city":     ["Tehran", "Mashhad", "Tehran"],
    "qty":      [2, 1, 3],
    "price":    [450_000, 320_000, 180_000],
})
df["total"] = df["qty"] * df["price"]
df["is_big"] = df["total"] > 500_000
print(df)
print("\nTotal:", f"{df['total'].sum():,}")
order_id city qty price total is_big 0 9001 Tehran 2 450000 900000 True 1 9002 Mashhad 1 320000 320000 False 2 9003 Tehran 3 180000 540000 True Total: 1,760,000

Column operations in pandas are vectorized: df["qty"] * df["price"] runs across every row without a loop. This improves both readability and speed compared with a Python loop.

Filter and sort rows in pandas.

▾Basic
big = df[df["total"] > 400_000]
tehran_big = df[(df["city"] == "Tehran") & (df["total"] > 400_000)]
top = df.sort_values("total", ascending=False).head(2)
print(tehran_big[["order_id", "total"]])
print(top[["order_id", "city", "total"]])
order_id total 0 9001 900000 2 9003 540000 order_id city total 0 9001 Tehran 900000 2 9003 Tehran 540000
In pandas you must use & and |, not and and or, and put each condition in parentheses. Forgetting the parentheses gives a cryptic error that beginners find hard to diagnose.

Know the common Python errors.

▾Basic
ErrorMeaningExample
SyntaxErrorThe code structure is wrongForgetting the colon after if
NameErrorUndefined variableA typo in a variable name
TypeErrorOperation on an unsuitable type"5" + 5
ValueErrorRight type, invalid valueint("abc")
KeyErrorThe key is not in the dictionaryd["x"]
IndexErrorIndex out of rangexs[10] on a 3-element list
IndentationErrorIncorrect indentationMixing spaces and tabs

Always read the last line of the error message first: the error type and its explanation are there. The lines above show the call path, which helps you find where the problem is.

D04

What is the pandas equivalent of a window function?

▾Intermediate
# Each order's share of the customer's total purchases — without losing rows
df["cust_total"] = df.groupby("customer_id")["amount"].transform("sum")
df["share"]      = df["amount"] / df["cust_total"]

# Rank within each group
df["rank_in_city"] = df.groupby("city")["amount"].rank(method="dense", ascending=False)

# Previous row and the difference
df = df.sort_values(["customer_id", "order_date"])
df["prev_amt"] = df.groupby("customer_id")["amount"].shift(1)
df["delta"]    = df["amount"] - df["prev_amt"]
customer_id amount cust_total share prev_amt delta 0 101 250000.0 1000000 0.25 NaN NaN 1 101 750000.0 1000000 0.75 250000.0 500000.0

Rule: agg collapses rows, transform keeps the original shape. This is exactly the difference between GROUP BY and OVER in SQL.

D05

Do a merge and make sure rows haven't been multiplied.

▾Intermediate
m = orders.merge(customers, on="customer_id", how="left",
                 validate="m:1", indicator=True)
print(m["_merge"].value_counts())
print("orders:", len(orders), "after merge:", len(m))
both 49813 left_only 187 right_only 0 Name: _merge, dtype: int64 orders: 50000 after merge: 50000

Two golden tools: validate="m:1", which raises an error if the relationship isn't what you think, and indicator=True, which shows how many rows didn't match. Writing these two in an interview looks very professional.

D06

Build a pivot table, then reverse it.

▾Intermediate
wide = df.pivot_table(index="city", columns="quarter",
                      values="amount", aggfunc="sum", fill_value=0)
print(wide.head(3))

long = wide.reset_index().melt(id_vars="city",
                               var_name="quarter", value_name="amount")
print(long.head(3))
quarter Q1 Q2 Q3 city Isfahan 12000000 14500000 13100000 Tehran 50000000 62000000 71000000 city quarter amount 0 Isfahan Q1 12000000 1 Tehran Q1 50000000

pivot_table works with aggfunc and aggregates duplicate values; plain pivot raises an error if it sees duplicates.

D07

Aggregate a time series to monthly and fill in the empty days.

▾Intermediate
ts = (df.set_index("order_date")
        .resample("D")["amount"].sum()
        .asfreq("D", fill_value=0))          # days without sales become zero
monthly = ts.resample("M").sum()
ma7 = ts.rolling(7, min_periods=1).mean()
print(monthly.head(3)); print(ma7.tail(3))
order_date 2024-01-31 120000000 2024-02-29 150000000 2024-03-31 135000000 Freq: M, Name: amount, dtype: int64 2024-08-29 612857.1 2024-08-30 598571.4 2024-08-31 604285.7

If you don't fill in the empty days, the moving average is computed over “7 days with data”, not 7 calendar days — a common and costly mistake.

D08

How do you handle missing values?

▾Intermediate
print(df.isna().mean().sort_values(ascending=False).head())

df["amount"] = df["amount"].fillna(df.groupby("city")["amount"].transform("median"))
df["status"] = df["status"].fillna("unknown")
df["last_login"] = df["last_login"].ffill()      # only makes sense for a time series
df = df.dropna(subset=["customer_id"])            # a key must not be empty
last_login 0.184 amount 0.004 status 0.000 dtype: float64

The key sentence: “Before filling, I first ask why it is missing.” Random missingness, systematic missingness (a logging bug) and meaningful missingness (the user hasn't bought yet) need three completely different treatments.

D09

Detect outliers with IQR.

▾Intermediate
q1, q3 = df["amount"].quantile([0.25, 0.75])
iqr = q3 - q1
lo, hi = q1 - 1.5*iqr, q3 + 1.5*iqr
out = df[(df.amount < lo) | (df.amount > hi)]
print(f"bounds: {lo:,.0f} .. {hi:,.0f}  | outliers: {len(out)} ({len(out)/len(df):.1%})")
bounds: -180,000 .. 1,420,000 | outliers: 1,204 (2.4%)

Blindly removing outliers is a mistake. In sales data, large corporate orders are real. Say: “I flag them and check with the data owner, rather than quickly deleting them.”

D10

Reduce the memory usage of a large DataFrame.

▾Intermediate
print(df.memory_usage(deep=True).sum() / 1e6, "MB")
df["status"] = df["status"].astype("category")
df["customer_id"] = pd.to_numeric(df["customer_id"], downcast="integer")
df["amount"] = pd.to_numeric(df["amount"], downcast="float")
print(df.memory_usage(deep=True).sum() / 1e6, "MB")
412.7 MB 96.3 MB

Three main levers: category for low-cardinality text columns, numeric downcasting, and reading only the needed columns with usecols. For a very large file, chunksize or moving to Parquet/Polars/DuckDB.

D11

Why is apply slow, and what replaces it?

▾Advanced
import numpy as np
# Slow: a Python loop over every row
df["level"] = df.apply(lambda r: "high" if r.amount > 1e6 else "low", axis=1)

# Fast: vectorization
df["level"] = np.where(df.amount > 1e6, "high", "low")

# Several conditions
df["seg"] = np.select(
    [df.amount > 5e6, df.amount > 1e6],
    ["vip", "high"], default="normal")
apply : 2.41 s np.where: 0.008 s (on 1 million rows)

apply(axis=1) is effectively a Python loop and doesn't use NumPy's C speed. If you write apply in live coding, add yourself “on large data I'd vectorize this part”.

D12

Merge two irregular time series with merge_asof.

▾Advanced
prices = prices.sort_values("ts"); trades = trades.sort_values("ts")
out = pd.merge_asof(trades, prices, on="ts", direction="backward",
                    tolerance=pd.Timedelta("5min"))
print(out.head())
ts trade_id price 0 2024-08-01 09:00:03 T101 15200.0 1 2024-08-01 09:04:41 T102 15230.0 2 2024-08-01 09:19:02 T103 NaN <- outside the tolerance

This is a “join on the nearest earlier time”. Uses: attaching the current price to a transaction, attaching a user's latest state to an event. Knowing this function signals real experience with time series.

D13

How do you process a 10 GB file that doesn't fit in RAM?

▾Advanced
# 1) Chunked processing
totals = {}
for chunk in pd.read_csv("big.csv", chunksize=500_000,
                         usecols=["city", "amount"]):
    s = chunk.groupby("city")["amount"].sum()
    for k, v in s.items(): totals[k] = totals.get(k, 0) + v

# 2) Columnar format + out-of-memory engine
df = pd.read_parquet("big.parquet", columns=["city", "amount"])

# 3) DuckDB directly on the file, without loading it
import duckdb
duckdb.sql("SELECT city, SUM(amount) FROM 'big.parquet' GROUP BY city").df()

A good order for the answer: first reduce the data (only the needed columns and rows), then a better format (Parquet), then a more suitable tool (DuckDB / Polars / Spark). Saying “I'd use Spark” as your first answer is usually seen as over-engineering.

D14

Connect to a database from Python and write the result.

▾Advanced
from sqlalchemy import create_engine, text
engine = create_engine("postgresql+psycopg2://user:pass@host:5432/db")

with engine.connect() as conn:
    df = pd.read_sql(text("""
        SELECT city, SUM(amount) AS rev
        FROM orders WHERE order_date >= :d GROUP BY city
    """), conn, params={"d": "2024-01-01"})

df.to_sql("agg_city_rev", engine, if_exists="replace",
          index=False, method="multi", chunksize=10_000)
city rev 0 Tehran 183000000 1 Mashhad 58000000
Never build a query with an f-string — SQL injection. Always use bound parameters (:d or %s). Be sure to say this yourself.
D15

Draw an analytical chart with matplotlib.

▾Advanced
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(ts.index, ts.values, alpha=0.35, label="Daily")
ax.plot(ma7.index, ma7.values, linewidth=2, label="7-day average")
ax.set_title("Daily revenue trend")
ax.set_ylabel("Amount (IRR)")
ax.legend(); ax.grid(alpha=0.3)
fig.tight_layout(); fig.savefig("revenue.png", dpi=150)

If they ask which chart for what: line for trends over time, bar for comparing categories, histogram for distributions, box plot for comparing distributions between groups, scatter for the relationship between two variables. Avoid pie charts as much as possible and say why: comparing angles is hard for the human eye.

NumPy and other libraries 14

N01

Why is NumPy faster than a Python list?

▾Basic
import numpy as np, time
a = np.arange(1_000_000)
lst = list(range(1_000_000))

t=time.perf_counter(); _ = a * 2;                 print("numpy", time.perf_counter()-t)
t=time.perf_counter(); _ = [x*2 for x in lst];    print("list ", time.perf_counter()-t)
numpy 0.0021 list 0.0684

Because a NumPy array is contiguous in memory and homogeneous, operations run in C (vectorization), and there is no Python object overhead per element.

N02

Show the basic array operations.

▾Basic
a = np.array([[1, 2, 3], [4, 5, 6]])
print(a.shape, a.dtype)
print(a.sum(axis=0), a.mean(axis=1))
print(a.reshape(3, 2))
print(a[a > 3])                      # boolean masking
(2, 3) int64 [5 7 9] [2. 5.] [[1 2] [3 4] [5 6]] [4 5 6]

axis=0 means “along the rows” (one result per column) and axis=1 the opposite. Always check this for yourself with a small example.

N03

What is broadcasting?

▾Intermediate
a = np.array([[1, 2, 3], [4, 5, 6]])   # (2,3)
b = np.array([10, 20, 30])             # (3,)
print(a + b)
print(a - a.mean(axis=1, keepdims=True))   # row-wise normalization
[[11 22 33] [14 25 36]] [[-1. 0. 1.] [-1. 0. 1.]]

Rule: dimensions are compared from the right and must be equal, or one of them must be 1. keepdims=True keeps the dimension so broadcasting works correctly.

N04

Write vectorized conditions with np.where and np.select.

▾Intermediate
x = np.array([5, 50, 500, 5000])
print(np.where(x > 100, "big", "small"))
print(np.select([x > 1000, x > 100], ["xl", "l"], default="s"))
print(np.clip(x, 10, 1000))            # clamp to a range
['small' 'small' 'big' 'big'] ['s' 's' 'l' 'xl'] [ 10 50 500 1000]
N05

Write a simple Monte Carlo simulation.

▾Intermediate
rng = np.random.default_rng(seed=42)
sims = rng.normal(loc=1_000_000, scale=200_000, size=100_000)
print(f"mean={sims.mean():,.0f}")
print(f"P5={np.percentile(sims,5):,.0f}  P95={np.percentile(sims,95):,.0f}")
print(f"P(>1.3M) = {(sims > 1_300_000).mean():.2%}")
mean=1,000,412 P5=671,214 P95=1,329,905 P(>1.3M) = 6.68%

Using default_rng(seed=...) instead of np.random.seed is the currently recommended approach and makes the result reproducible.

N06

Fetch data from an API with requests and handle errors.

▾Intermediate
import requests, time

def fetch(url, params=None, retries=3):
    for i in range(retries):
        try:
            r = requests.get(url, params=params, timeout=10)
            r.raise_for_status()
            return r.json()
        except requests.HTTPError as e:
            if r.status_code == 429:            # rate limit
                time.sleep(2 ** i); continue
            raise
        except requests.RequestException:
            if i == retries - 1: raise
            time.sleep(2 ** i)                  # exponential backoff

data = fetch("https://api.example.com/orders", {"page": 1})
df = pd.json_normalize(data["items"])
id customer.name customer.city amount 0 9001 Maryam Tehran 850000

Two points that score: always set a timeout (otherwise the script can hang forever) and use pd.json_normalize to flatten nested JSON.

N07

Work with datetime: parsing, time zones, differences.

▾Intermediate
from datetime import datetime, timedelta, timezone
s = "2024-08-31 14:20:00"
dt = datetime.strptime(s, "%Y-%m-%d %H:%M:%S")
print(dt.strftime("%Y/%m/%d"), (dt + timedelta(days=7)).date())

# In pandas, with time zones
ts = pd.to_datetime(df["created_at"], utc=True).dt.tz_convert("Asia/Tehran")
print(ts.dt.date.head(2).tolist(), ts.dt.dayofweek.head(2).tolist())
2024/08/31 2024-09-07 [datetime.date(2024, 8, 31), datetime.date(2024, 8, 31)] [5, 5]
A common trap in reporting: the data is stored in UTC but the report must be in Tehran time. If you don't convert, the day boundary shifts and the daily numbers come out wrong. Be sure to raise this in the interview.
N08

Work with json and files.

▾Intermediate
import json
from pathlib import Path

rec = {"city": "Tehran", "rev": 1_800_000}
Path("out.json").write_text(json.dumps(rec, ensure_ascii=False, indent=2),
                            encoding="utf-8")

# JSONL file (one record per line) — a common format for logs and Kafka dumps
rows = [json.loads(l) for l in Path("events.jsonl").read_text(encoding="utf-8").splitlines() if l]
df = pd.DataFrame(rows)
{ "city": "Tehran", "rev": 1800000 }

Don't forget ensure_ascii=False, otherwise non-ASCII text such as Persian is stored as escapes like \u062a.

Write a function that reports the quality of every column in a DataFrame.

▾Intermediate
def profile(df, top_n=3):
    rows = []
    for c in df.columns:
        s = df[c]
        rows.append({
            "column":   c,
            "dtype":    str(s.dtype),
            "nulls_%":  round(100 * s.isna().mean(), 2),
            "unique":   s.nunique(dropna=True),
            "unique_%": round(100 * s.nunique(dropna=True) / max(len(s), 1), 2),
            "top":      " | ".join(map(str, s.value_counts().head(top_n).index)),
            "min":      s.min() if pd.api.types.is_numeric_dtype(s) else "",
            "max":      s.max() if pd.api.types.is_numeric_dtype(s) else "",
        })
    return pd.DataFrame(rows).sort_values("nulls_%", ascending=False)

print(profile(df).head())
column dtype nulls_% unique unique_% top 3 discount float64 18.40 142 0.28 0 | 5000 5 city object 0.90 31 0.06 Tehran | Mashhad 0 order_id int64 0.00 50000 100.00 9001 | 9002

A unique_% of one hundred means a candidate key, and near zero means an almost constant column that is probably useless. Run this function at the start of every project; it takes twenty seconds and usually exposes several serious data problems right away.

N09

Build a simple predictive model with scikit-learn.

▾Advanced
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.compose import ColumnTransformer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, roc_auc_score

X, y = df.drop(columns="churned"), df["churned"]
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.2,
                                      stratify=y, random_state=42)

pre = ColumnTransformer([
    ("num", StandardScaler(), ["recency", "frequency", "monetary"]),
    ("cat", OneHotEncoder(handle_unknown="ignore"), ["city", "channel"]),
])
model = Pipeline([("pre", pre), ("clf", LogisticRegression(max_iter=1000))])
model.fit(Xtr, ytr)
proba = model.predict_proba(Xte)[:, 1]
print(f"AUC = {roc_auc_score(yte, proba):.3f}")
print(classification_report(yte, proba > 0.5))
AUC = 0.842 precision recall f1-score 0 0.91 0.94 0.92 1 0.71 0.62 0.66

Three things the interviewer is looking for: a Pipeline so data leakage doesn't happen, stratify for imbalanced data, and knowing that accuracy is a poor metric for an imbalanced class and that you should use AUC / precision-recall.

N10

Analyze an A/B test statistically.

▾Advanced
from scipy import stats
import numpy as np

conv_a, n_a = 480, 10_000     # control
conv_b, n_b = 545, 10_000     # treatment
p_a, p_b = conv_a/n_a, conv_b/n_b

count = np.array([conv_a, conv_b]); nobs = np.array([n_a, n_b])
from statsmodels.stats.proportion import proportions_ztest
z, p = proportions_ztest(count, nobs)
lift = (p_b - p_a) / p_a
print(f"A={p_a:.3%}  B={p_b:.3%}  lift={lift:+.1%}  z={z:.2f}  p={p:.4f}")
A=4.800% B=5.450% lift=+13.5% z=-2.07 p=0.0387

The right conclusion: “p is below 0.05, so the difference is statistically significant — but before deciding I need to check that the sample size was fixed in advance (no peeking), that the period covers a full weekly cycle, and that the lift is worth implementing from a business point of view.” Separating statistical significance from practical significance comes across very well.

N11

Read messages with kafka-python and write them to a database.

▾Advanced
from kafka import KafkaConsumer
import json

consumer = KafkaConsumer(
    "orders",
    bootstrap_servers=["kafka:9092"],
    group_id="etl-orders",
    auto_offset_reset="earliest",
    enable_auto_commit=False,                 # manual offset control
    value_deserializer=lambda b: json.loads(b.decode("utf-8")),
)

batch = []
for msg in consumer:
    batch.append(msg.value)
    if len(batch) >= 1000:
        write_to_db(batch)                    # write first
        consumer.commit()                     # then commit → at-least-once
        batch.clear()

The order “write first, then commit” means at-least-once: duplicate records may arrive, so writing must be idempotent (an upsert on a unique key). If you reverse it, it becomes at-most-once and data may be lost. This analysis is exactly what they want in a Kafka interview.

N12

How do you structure a schedulable ETL pipeline?

▾Advanced
project/
├── config.yaml            # connections and parameters, no passwords in code
├── src/
│   ├── extract.py         # read from the source
│   ├── transform.py       # transformation logic, pure and testable functions
│   ├── load.py            # idempotent writes (upsert)
│   └── main.py            # orchestration + logging + error handling
├── tests/
└── requirements.txt
def run(run_date):
    log.info("start %s", run_date)
    raw   = extract(run_date)
    clean = transform(raw)
    load(clean, partition=run_date)      # drop the day's partition and re-insert
    log.info("rows=%d done", len(clean))

Keywords you should say: idempotent (re-running the same day doesn't double the result), backfill (running for past dates with a run_date parameter), and a data quality check after the load (row counts, NULLs in keys, control totals).

Process a very large file with limited memory.

▾Advanced
import pandas as pd
from collections import defaultdict

totals = defaultdict(float)
counts = defaultdict(int)

reader = pd.read_csv("orders_50gb.csv", chunksize=500_000,
                     usecols=["city", "status", "amount"],
                     dtype={"city": "category", "status": "category",
                            "amount": "float32"})
for chunk in reader:
    paid = chunk[chunk.status == "paid"]
    g = paid.groupby("city", observed=True)["amount"].agg(["sum", "count"])
    for city, row in g.iterrows():
        totals[city] += row["sum"]; counts[city] += row["count"]

out = (pd.DataFrame({"revenue": totals, "orders": counts})
         .assign(aov=lambda x: x.revenue / x.orders)
         .sort_values("revenue", ascending=False))
print(out.head(3).round(0))
revenue orders aov Tehran 1.83e+09 11200 163392.0 Mashhad 5.80e+08 4020 144278.0

Three main levers: chunksize for chunked processing, usecols to read only the needed columns, and dtype with category and float32, which cuts memory use several times over. If this is a recurring job, move to Polars or DuckDB, which do the same thing with simpler, faster code.

Python live-coding exercises 11

C01

Find the most frequent words in a text.

▾Basic
from collections import Counter
import re

def top_words(text, n=3, stop=None):
    stop = stop or set()
    words = [w for w in re.findall(r"[\w']+", text.lower()) if w not in stop]
    return Counter(words).most_common(n)

print(top_words("data is data and data wins", n=2, stop={"is", "and"}))
[('data', 3), ('wins', 1)]

With re.findall, punctuation is dropped automatically — better than split(). If they ask about edge cases: empty text, capital letters, stop words.

C02

Two numbers in a list that add up to the target.

▾Basic
def two_sum(nums, target):
    seen = {}                      # value -> index
    for i, x in enumerate(nums):
        if target - x in seen:
            return seen[target - x], i
        seen[x] = i
    return None

print(two_sum([2, 7, 11, 15], 9))
(0, 1)

With a dict, one pass: O(n) time and O(n) memory. With two nested loops it becomes O(n²) — always state the complexity out loud.

C03

Flatten a nested list.

▾Basic
def flatten(x):
    for item in x:
        if isinstance(item, (list, tuple)):
            yield from flatten(item)
        else:
            yield item

print(list(flatten([1, [2, [3, 4]], (5, 6)])))
[1, 2, 3, 4, 5, 6]

yield from makes the recursion readable. For a single level there is a simpler way: [y for sub in x for y in sub].

C04

Group and aggregate a list of dictionaries — without pandas.

▾Intermediate
from collections import defaultdict

orders = [
    {"city": "Tehran", "amount": 500}, {"city": "Mashhad", "amount": 300},
    {"city": "Tehran", "amount": 200}, {"city": "Mashhad", "amount": 100},
]

agg = defaultdict(lambda: {"total": 0, "count": 0})
for o in orders:
    agg[o["city"]]["total"] += o["amount"]
    agg[o["city"]]["count"] += 1

result = sorted(
    ({"city": c, **v, "avg": v["total"] / v["count"]} for c, v in agg.items()),
    key=lambda r: -r["total"])
for r in result: print(r)
{'city': 'Tehran', 'total': 700, 'count': 2, 'avg': 350.0} {'city': 'Mashhad', 'total': 400, 'count': 2, 'avg': 200.0}

This question is popular because it tests whether you understand grouping logic without pandas. defaultdict frees the code from repeated conditions.

C05

Read a large log file and count errors by hour.

▾Intermediate
from collections import Counter

def error_by_hour(path):
    c = Counter()
    with open(path, encoding="utf-8") as f:      # line by line, without loading it all
        for line in f:
            if " ERROR " not in line:
                continue
            hour = line[11:13]                   # 2024-08-31 14:20:11 ERROR ...
            c[hour] += 1
    return dict(sorted(c.items()))

print(error_by_hour("app.log"))
{'09': 4, '10': 2, '14': 31, '15': 27}

An important point to make: I read the file line by line, not with a full read() — because a log file may be several gigabytes. The peak at 14:00 is also where you'd start investigating the incident.

C07

Write a data-cleaning function that fixes several common problems.

▾Intermediate
import re, unicodedata

FA_DIGITS = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789")

def clean_text(s):
    if s is None: return ""
    s = unicodedata.normalize("NFKC", str(s))
    s = s.translate(FA_DIGITS)              # Persian/Arabic digits to Latin
    s = s.replace("ي", "ی").replace("ك", "ک")   # Arabic letters to Persian
    s = re.sub(r"\s+", " ", s).strip()      # extra whitespace
    return s

print(repr(clean_text("  كتاب   ۱۲۳ ")))
'کتاب 123'

If the company works with Persian text data, this function is gold: unifying the Arabic and Persian forms of “ی” and “ک”, Persian digits, zero-width non-joiners and extra spaces. Bringing it up shows you've dealt with real-world Persian data.

C08

Flatten a nested dictionary.

▾Intermediate
def flatten_dict(d, parent="", sep="."):
    out = {}
    for k, v in d.items():
        key = f"{parent}{sep}{k}" if parent else k
        if isinstance(v, dict):
            out.update(flatten_dict(v, key, sep))
        else:
            out[key] = v
    return out

print(flatten_dict({"user": {"name": "Ali", "addr": {"city": "Tehran"}}, "amt": 100}))
{'user.name': 'Ali', 'user.addr.city': 'Tehran', 'amt': 100}

Exactly what pd.json_normalize does. If you say “in practice I'd use json_normalize, but this is the logic”, you come across as both practical and in command of the material.

C09

Merge overlapping time intervals.

▾Advanced
def merge_intervals(intervals):
    if not intervals: return []
    intervals = sorted(intervals, key=lambda x: x[0])
    out = [list(intervals[0])]
    for s, e in intervals[1:]:
        if s <= out[-1][1]:              # overlap
            out[-1][1] = max(out[-1][1], e)
        else:
            out.append([s, e])
    return [tuple(x) for x in out]

print(merge_intervals([(1, 3), (2, 6), (8, 10), (9, 12)]))
[(1, 6), (8, 12)]

O(n log n) because of the sort. A real use: computing a user's total active time from overlapping sessions, or a service's downtime from several alerts.

C10

Sliding window with deque — the maximum of each window of size k.

▾Advanced
from collections import deque

def max_sliding(nums, k):
    dq, out = deque(), []          # indexes, in decreasing order of value
    for i, x in enumerate(nums):
        while dq and nums[dq[-1]] <= x: dq.pop()
        dq.append(i)
        if dq[0] <= i - k: dq.popleft()
        if i >= k - 1: out.append(nums[dq[0]])
    return out

print(max_sliding([1, 3, -1, -3, 5, 3, 6, 7], 3))
[3, 3, 5, 5, 6, 7]

O(n) — each element enters and leaves at most once. An O(n·k) solution is also acceptable, but say you know a linear version exists.

C11

Build an RFM matrix and segment the customers.

▾Advanced
import pandas as pd
snapshot = df["order_date"].max() + pd.Timedelta(days=1)

rfm = df.groupby("customer_id").agg(
    recency=("order_date", lambda s: (snapshot - s.max()).days),
    frequency=("order_id", "count"),
    monetary=("amount", "sum"))

rfm["R"] = pd.qcut(rfm.recency, 4, labels=[4, 3, 2, 1]).astype(int)   # more recent = better
rfm["F"] = pd.qcut(rfm.frequency.rank(method="first"), 4, labels=[1, 2, 3, 4]).astype(int)
rfm["M"] = pd.qcut(rfm.monetary, 4, labels=[1, 2, 3, 4]).astype(int)
rfm["score"] = rfm.R + rfm.F + rfm.M

rfm["segment"] = pd.cut(rfm.score, [0, 5, 8, 10, 12],
                        labels=["at risk", "regular", "loyal", "champion"])
print(rfm.head(3)); print(rfm.segment.value_counts())
recency frequency monetary R F M score segment customer_id 101 12 8 4200000 4 4 4 12 champion 102 145 1 180000 1 1 1 3 at risk regular 1820 at risk 940 loyal 610 champion 380

The frequency.rank(method="first") trick is for when many repeated values cause a “bin edges must be unique” error in qcut. This detail shows you've really built an RFM model.

C12

Write a function that checks and reports data quality.

▾Advanced
def data_quality_report(df, key, non_null=(), ranges=None):
    issues = []
    if df[key].duplicated().any():
        issues.append(f"Duplicate key: {df[key].duplicated().sum()} rows")
    for c in non_null:
        n = df[c].isna().sum()
        if n: issues.append(f"NULL in {c}: {n}")
    for c, (lo, hi) in (ranges or {}).items():
        bad = ((df[c] < lo) | (df[c] > hi)).sum()
        if bad: issues.append(f"Out of range in {c}: {bad}")
    if df.empty: issues.append("DataFrame is empty")
    return issues or ["No issues"]

print(data_quality_report(df, key="order_id",
                          non_null=["customer_id", "amount"],
                          ranges={"amount": (0, 5e8)}))
['NULL in amount: 213', 'Out of range in amount: 4']

In production you put this as an assert at the end of the pipeline so a faulty run makes noise. Mentioning Great Expectations or dbt tests earns extra points.

Algorithms and classic problems 17

How do you express time and space complexity?

▾Basic
Operationlistset / dict
Access by indexO(1)—
Membership test inO(n)O(1) average
Append to the endO(1) amortizedO(1)
Insert at the startO(n)—
SortingO(n log n)—
# O(n²) — a linear search for every element
dupes = [x for i, x in enumerate(xs) if x in xs[:i]]

# O(n) — with a set
seen, dupes = set(), set()
for x in xs:
    if x in seen: dupes.add(x)
    seen.add(x)

In an interview, always state the time and space complexity out loud after writing the code. Even if your solution isn't optimal, knowing that it isn't earns points in itself.

Classic problem: checking palindromes and anagrams.

▾Basic
import re
from collections import Counter

def is_palindrome(s):
    t = re.sub(r"[^a-z0-9]", "", s.lower())
    return t == t[::-1]

def is_anagram(a, b):
    return Counter(a.replace(" ", "").lower()) == Counter(b.replace(" ", "").lower())

print(is_palindrome("A man, a plan, a canal: Panama"))
print(is_anagram("listen", "silent"))
True True

The palindrome solution with a reversed slice is O(n) time and O(n) memory. If they want constant memory, walk in with two pointers from both ends of the string. For anagrams, Counter is O(n), while sorting both strings is O(n log n).

Classic problem: FizzBuzz and counting characters.

▾Basic
def fizzbuzz(n):
    for i in range(1, n + 1):
        out = ("Fizz" if i % 3 == 0 else "") + ("Buzz" if i % 5 == 0 else "")
        print(out or i)

fizzbuzz(15)

from collections import Counter
print(Counter("mississippi").most_common(2))
1 2 Fizz 4 Buzz Fizz 7 8 Fizz Buzz 11 Fizz 13 14 FizzBuzz [('i', 4), ('s', 4)]

The version above uses string concatenation instead of four nested conditions, so adding a new rule (say, sevens) is a single line. Interviewers usually ask exactly this after the first answer: “Now add another rule.”

Write the same first-trade analysis in pandas.

▾Intermediate
import pandas as pd

first = (trades.groupby("user_id", as_index=False)["trade_time"]
               .min().rename(columns={"trade_time": "first_trade_at"}))

tx = txns.merge(first, on="user_id", how="right")     # right, so users without transactions stay
before = tx[tx["txn_time"] < tx["first_trade_at"]]

agg = (before.pivot_table(index=["user_id", "first_trade_at"],
                          columns="txn_type", values="amount",
                          aggfunc="sum", fill_value=0)
             .reset_index()
             .rename(columns={"deposit": "deposit_before",
                              "withdrawal": "withdraw_before"}))

out = first.merge(agg, on=["user_id", "first_trade_at"], how="left").fillna(0)
out["net_funding"] = out["deposit_before"] - out["withdraw_before"]
print(out.sort_values("net_funding", ascending=False).head(3))
user_id first_trade_at deposit_before withdraw_before net_funding 0 7712 2024-03-04 10:12:00 85000000.0 10000000.0 75000000.0 1 4410 2024-05-19 09:40:00 20000000.0 0.0 20000000.0 2 9021 2024-06-02 11:05:00 0.0 0.0 0.0

Note that how="right" and then how="left" are deliberate: at both steps we don't want to drop a user with no prior transactions. This is the same INNER JOIN trap from the SQL version, appearing in a different form.

Classic problem: the longest substring without repeating characters.

▾Intermediate
def longest_unique(s):
    last = {}          # character -> last index seen
    left = best = start = 0
    for right, ch in enumerate(s):
        if ch in last and last[ch] >= left:
            left = last[ch] + 1        # move the window past the previous repeat
        last[ch] = right
        if right - left + 1 > best:
            best, start = right - left + 1, left
    return best, s[start:start + best]

print(longest_unique("abcabcbb"))
print(longest_unique("pwwkew"))
(3, 'abc') (3, 'wke')

The same sliding-window pattern as the leave problem, with a different condition. The last[ch] >= left check is essential: without it, a character seen outside the current window also makes the left pointer jump incorrectly.

Classic problem: maximum subarray sum (Kadane's algorithm).

▾Intermediate
def max_subarray(nums):
    best = cur = nums[0]
    start = end = tmp = 0
    for i in range(1, len(nums)):
        if cur < 0:
            cur, tmp = nums[i], i      # fresh start
        else:
            cur += nums[i]
        if cur > best:
            best, start, end = cur, tmp, i
    return best, nums[start:end + 1]

print(max_subarray([-2, 1, -3, 4, -1, 2, 1, -5, 4]))
(6, [4, -1, 2, 1])

The intuition: if the running sum so far goes negative, continuing with it never helps, so start over from the next element. A real use is finding the worst or best continuous period of profit and loss in financial data.

Classic problem: binary search and the first value greater than or equal to a target.

▾Intermediate
import bisect

def lower_bound(arr, target):
    lo, hi = 0, len(arr)
    while lo < hi:
        mid = (lo + hi) // 2
        if arr[mid] < target:
            lo = mid + 1
        else:
            hi = mid
    return lo

a = [1, 3, 3, 5, 8, 13]
print(lower_bound(a, 3), bisect.bisect_left(a, 3))
print(lower_bound(a, 6), bisect.bisect_left(a, 6))
1 1 4 4

Two points: in Python always use bisect, but you should be able to write it by hand too. And make the loop condition lo < hi with hi = mid, not lo <= hi; this version is less prone to infinite loops and off-by-one errors.

Classic problem: balanced parentheses and evaluation with a stack.

▾Intermediate
def is_balanced(s):
    pairs = {")": "(", "]": "[", "}": "{"}
    stack = []
    for ch in s:
        if ch in "([{":
            stack.append(ch)
        elif ch in pairs:
            if not stack or stack.pop() != pairs[ch]:
                return False
    return not stack

print(is_balanced("SELECT (a, [b, {c}])"))
print(is_balanced("SELECT (a, [b)]"))
True False

An edge case many people forget: the final not stack check. The string "(((" produces no error inside the loop and, without this line, is wrongly reported as balanced.

Classic problem: the k most frequent elements.

▾Intermediate
from collections import Counter
import heapq

def top_k(nums, k):
    counts = Counter(nums)
    return heapq.nlargest(k, counts.items(), key=lambda kv: kv[1])

print(top_k(["a","b","a","c","b","a"], 2))
print(Counter(["a","b","a","c","b","a"]).most_common(2))
[('a', 3), ('b', 2)] [('a', 3), ('b', 2)]

A full sort is O(n log n), but a heap of size k brings the problem down to O(n log k), which makes a real difference when k is much smaller than n. If k is close to n, a plain sort is better — state exactly this trade-off in the interview.

Classic problem: Fibonacci with memoization and an iterative version.

▾Intermediate
from functools import lru_cache

@lru_cache(maxsize=None)
def fib_memo(n):
    return n if n < 2 else fib_memo(n-1) + fib_memo(n-2)

def fib_iter(n):
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a

print(fib_memo(60), fib_iter(60))
1548008755920 1548008755920

The recursive version without memoization is exponential and practically never finishes for n=50. With lru_cache it becomes linear but uses memory and hits Python's recursion limit at large depths. The iterative version is better on both counts; this comparison is the essence of dynamic programming.

The same leave problem: with 10 days of leave, find the longest continuous break in Python.

▾Advanced

The input is a 365-item list with zero for days off and one for working days. We want the longest window containing at most 10 working days. The solution pattern is a two-pointer sliding window, which runs in linear time.

from datetime import date, timedelta

def best_vacation(is_workday, start=date(2025, 1, 1), leave=10):
    """is_workday: list of 0s and 1s for the days of the year. 0 means already a day off."""
    left = 0
    used = 0          # working days inside the current window
    best = (0, 0, 0)  # (length, start, end)

    for right, work in enumerate(is_workday):
        used += work
        while used > leave:            # shrink the window from the left
            used -= is_workday[left]
            left += 1
        if right - left + 1 > best[0]:
            best = (right - left + 1, left, right)

    length, i, j = best
    take = [start + timedelta(days=k)
            for k in range(i, j + 1) if is_workday[k] == 1]
    return {
        "start":      start + timedelta(days=i),
        "end":        start + timedelta(days=j),
        "total_days": length,
        "leave_days": take,
    }

# Synthetic sample: weekends off + a few public holidays
cal = [0 if (date(2025,1,1) + timedelta(days=k)).weekday() in (3, 4) else 1
       for k in range(365)]
for k in (73, 74, 75, 76, 77):       # hypothetical Nowruz holidays
    cal[k] = 0

res = best_vacation(cal)
print(res["start"], "→", res["end"], "|", res["total_days"], "consecutive days")
print("Leave days:", len(res["leave_days"]))
print([d.isoformat() for d in res["leave_days"]][:4], "...")
2025-03-13 → 2025-04-03 | 22 consecutive days Leave days: 10 ['2025-03-17', '2025-03-18', '2025-03-19', '2025-03-20'] ...

Why does the sliding window work? Because the condition “at most 10 working days” is monotonic: if a window is valid, every sub-window of it is valid too. So when the window becomes invalid we only need to shrink it from the left, and never need to go back. Each element enters and leaves at most once, so the complexity is O(n).

In the interview, first describe the simple quadratic solution (checking every window), then say “but because the condition is monotonic, I can reach linear time with two pointers”. Showing this line of thought earns more than writing the optimal answer straight away.

Edge cases to raise: if the whole year is working days, the answer is exactly 10 days; if leave is zero, it should return the longest existing break; and if there are several windows of equal length, this code returns the first, so it's better to define an explicit tie-breaker.

Classic problem: implement an LRU cache.

▾Advanced
from collections import OrderedDict

class LRUCache:
    def __init__(self, capacity):
        self.cap = capacity
        self.data = OrderedDict()

    def get(self, key):
        if key not in self.data:
            return -1
        self.data.move_to_end(key)      # most recently used
        return self.data[key]

    def put(self, key, value):
        if key in self.data:
            self.data.move_to_end(key)
        self.data[key] = value
        if len(self.data) > self.cap:
            self.data.popitem(last=False)   # drop the oldest

c = LRUCache(2)
c.put("a", 1); c.put("b", 2); c.get("a"); c.put("c", 3)
print(list(c.data.items()))
[('a', 1), ('c', 3)]

Both operations are constant time. If they want it without OrderedDict, you need a dictionary plus a doubly linked list — which is what OrderedDict does internally. A real use is a cache layer for repeated dashboard queries.

Classic problem: implement and compare merge sort and quicksort.

▾Advanced
def merge_sort(a):
    if len(a) <= 1:
        return a
    mid = len(a) // 2
    left, right = merge_sort(a[:mid]), merge_sort(a[mid:])
    out, i, j = [], 0, 0
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:
            out.append(left[i]); i += 1
        else:
            out.append(right[j]); j += 1
    return out + left[i:] + right[j:]

def quick_sort(a):
    if len(a) <= 1:
        return a
    pivot = a[len(a) // 2]
    lo  = [x for x in a if x < pivot]
    eq  = [x for x in a if x == pivot]
    hi  = [x for x in a if x > pivot]
    return quick_sort(lo) + eq + quick_sort(hi)

print(merge_sort([5, 2, 9, 1, 5, 6]))
print(quick_sort([5, 2, 9, 1, 5, 6]))
[1, 2, 5, 5, 6, 9] [1, 2, 5, 5, 6, 9]
Merge SortQuick Sort
Average timeO(n log n)O(n log n)
Worst caseO(n log n)O(n²)
Extra memoryO(n)O(log n) in the in-place version
StableYesNo

Stability means the relative order of equal elements is preserved — which matters in multi-step sorting of data. Python's sorted uses Timsort, which is stable and very fast on partially sorted data.

Classic problem: graph traversal with BFS and DFS.

▾Advanced
from collections import deque, defaultdict

graph = defaultdict(list, {
    "raw_orders":   ["stg_orders"],
    "raw_customers":["stg_customers"],
    "stg_orders":   ["fct_sales"],
    "stg_customers":["fct_sales"],
    "fct_sales":    ["dashboard"],
})

def bfs(start):
    seen, order, q = {start}, [], deque([start])
    while q:
        node = q.popleft()
        order.append(node)
        for nxt in graph[node]:
            if nxt not in seen:
                seen.add(nxt); q.append(nxt)
    return order

def dfs(node, seen=None):
    seen = seen or set()
    seen.add(node)
    out = [node]
    for nxt in graph[node]:
        if nxt not in seen:
            out += dfs(nxt, seen)
    return out

print(bfs("raw_orders"))
print(dfs("raw_orders"))
['raw_orders', 'stg_orders', 'fct_sales', 'dashboard'] ['raw_orders', 'stg_orders', 'fct_sales', 'dashboard']

This structure is exactly the dependency graph of dbt models or Airflow tasks. BFS suits “shortest path” and DFS suits “all downstream dependencies”. If they ask for the order in which to run the tasks, the answer is a topological sort.

Classic problem: the number of ways to make change for an amount, with dynamic programming.

▾Advanced
def coin_ways(amount, coins):
    dp = [0] * (amount + 1)
    dp[0] = 1
    for c in coins:                 # outer loop over coins = combinations, not permutations
        for x in range(c, amount + 1):
            dp[x] += dp[x - c]
    return dp[amount]

def min_coins(amount, coins):
    INF = float("inf")
    dp = [0] + [INF] * amount
    for x in range(1, amount + 1):
        for c in coins:
            if c <= x:
                dp[x] = min(dp[x], dp[x - c] + 1)
    return dp[amount] if dp[amount] < INF else -1

print(coin_ways(50_000, [5_000, 10_000, 20_000]))
print(min_coins(50_000, [5_000, 10_000, 20_000]))
6 3

The order of the two loops is decisive: coins outside means you count combinations, amount outside means you count permutations. Most people who get this question wrong go wrong exactly here, not in the dynamic programming idea.

Classic problem: detecting a cycle in a dependency graph.

▾Advanced
def has_cycle(graph):
    WHITE, GRAY, BLACK = 0, 1, 2
    color = {n: WHITE for n in graph}

    def visit(n, path):
        color[n] = GRAY
        for nxt in graph.get(n, []):
            if color.get(nxt, WHITE) == GRAY:
                return path + [nxt]           # cycle found
            if color.get(nxt, WHITE) == WHITE:
                found = visit(nxt, path + [nxt])
                if found: return found
        color[n] = BLACK
        return None

    for n in graph:
        if color[n] == WHITE:
            found = visit(n, [n])
            if found: return found
    return None

g = {"a": ["b"], "b": ["c"], "c": ["a"]}
print(has_cycle(g))
['a', 'b', 'c', 'a']

Gray means “currently being visited on the current path”. Reaching a gray node again is the exact definition of a cycle. It is the same check Airflow and dbt perform when building the task graph, to prevent circular dependencies.

Classic problem: a moving average over a data stream with constant memory.

▾Advanced
from collections import deque

class MovingAverage:
    def __init__(self, size):
        self.size = size
        self.q = deque()
        self.total = 0.0

    def next(self, val):
        self.q.append(val)
        self.total += val
        if len(self.q) > self.size:
            self.total -= self.q.popleft()
        return self.total / len(self.q)

m = MovingAverage(3)
print([round(m.next(x), 2) for x in [10, 20, 30, 40]])
[10.0, 15.0, 20.0, 26.67]

Instead of summing the window again at every step (which is n×k), we keep the sum and subtract only the element that leaves; each call is constant time. A practical note: on very long streams of floating-point numbers, accumulated floating-point error builds up, and sometimes you need to recompute periodically.

Classic problems in pandas 14

1. Find duplicate records in pandas

▾Basic
import pandas as pd

# Count the duplicates
dups = (df.groupby("email").size()
          .loc[lambda s: s > 1]
          .sort_values(ascending=False))
print(dups.head())

# The duplicate rows themselves (every copy, not just the second onward)
mask = df.duplicated(subset=["email"], keep=False)
print(df[mask].sort_values("email").head())

# Duplicates by a composite key
print(df.duplicated(subset=["first_name", "last_name", "birth_date"]).sum())
email a@example.com 3 b@example.com 2 dtype: int64 Duplicate rows by composite key: 14

The keep argument has three settings: 'first' keeps the first and counts the rest as duplicates, 'last' the reverse, and False marks every copy as a duplicate. To “see” the duplicates, False is always right.

Rename and reorder the columns of a DataFrame.

▾Basic
df = df.rename(columns={"cust_id": "customer_id", "amt": "amount"})
df.columns = df.columns.str.strip().str.lower().str.replace(" ", "_")

cols = ["order_id", "customer_id", "order_date", "amount"]
df = df[cols + [c for c in df.columns if c not in cols]]   # important columns first
print(df.columns.tolist()[:6])
['order_id', 'customer_id', 'order_date', 'amount', 'status', 'city']

Run the second line on every raw file that comes out of Excel: column names like "Order Date " are an endless source of KeyErrors.

Read several CSV files at once and combine them.

▾Basic
from pathlib import Path
import pandas as pd

files = sorted(Path("data/monthly").glob("sales_*.csv"))
frames = []
for f in files:
    part = pd.read_csv(f, parse_dates=["order_date"])
    part["source_file"] = f.name          # track the origin for troubleshooting
    frames.append(part)

df = pd.concat(frames, ignore_index=True)
print(len(files), "files |", len(df), "rows")
print(df.groupby("source_file").size().head(3))
12 files | 148,320 rows source_file sales_2024_01.csv 11840 sales_2024_02.csv 12210

Keep the source_file column. When one file is missing a column or has a different date format, this column is the only quick way to find the culprit.

2. The Nth highest value in pandas

▾Intermediate
def nth_highest(s, n):
    vals = s.dropna().drop_duplicates().sort_values(ascending=False)
    return vals.iloc[n - 1] if len(vals) >= n else None

print(nth_highest(df["salary"], 3))
print(df["salary"].nlargest(3).iloc[-1])          # without removing duplicates
print(df["salary"].rank(method="dense", ascending=False).eq(3).any())
42000000 44000000 True

Explain the difference between the first and second lines: nlargest counts duplicate values separately, so if two people share the highest salary the result differs. The pandas equivalent of DENSE_RANK is rank(method="dense").

3. Top N records per group

▾Intermediate
rev = (df.groupby(["category", "product_name"], as_index=False)["revenue"].sum())

# Approach 1: sort and groupby.head — simple and fast
top3 = (rev.sort_values(["category", "revenue"], ascending=[True, False])
           .groupby("category").head(3))

# Approach 2: with rank, when you also want the rank number
rev["rn"] = rev.groupby("category")["revenue"].rank(method="first", ascending=False)
top3b = rev[rev["rn"] <= 3].sort_values(["category", "rn"])
print(top3b.head(6))
category product_name revenue rn 0 Accessories Keyboard 310000000 1.0 1 Accessories Mouse 220000000 2.0 3 Audio Headphones 480000000 1.0 4 Audio Speaker 190000000 2.0

The method parameter of rank maps exactly onto the choice between SQL window functions: "first" is like ROW_NUMBER, "min" like RANK and "dense" like DENSE_RANK.

4. Running total in pandas

▾Intermediate
daily = (df[df.status == "paid"]
           .groupby(df.order_date.dt.date)["amount"].sum()
           .rename("daily").to_frame())

daily["cumulative"] = daily["daily"].cumsum()

# Cumulative within each month, with a reset
daily.index = pd.to_datetime(daily.index)
daily["mtd"] = daily.groupby(daily.index.to_period("M"))["daily"].cumsum()
print(daily.tail(3))
daily cumulative mtd 2024-08-30 620000 41180000 8940000 2024-08-31 700000 41880000 9640000 2024-09-01 510000 42390000 510000

The groupby(...).cumsum() pattern is the direct equivalent of SUM(...) OVER (PARTITION BY ... ORDER BY ...). For a cumulative sum within a group, always sort the data by time first, because pandas uses the current row order, not the index.

5. Moving average in pandas

▾Intermediate
ts = (daily["daily"]
        .asfreq("D", fill_value=0))          # days without sales become zero

out = pd.DataFrame({
    "rev":  ts,
    "ma7":  ts.rolling(7,  min_periods=1).mean().round(),
    "ma30": ts.rolling(30, min_periods=1).mean().round(),
    "ma7_strict": ts.rolling(7).mean().round(),   # the start of the range stays NaN
    "ewm":  ts.ewm(span=7, adjust=False).mean().round(),
})
print(out.head(4))
rev ma7 ma30 ma7_strict ewm 2024-08-01 500000 500000 500000 NaN 500000 2024-08-02 300000 400000 400000 NaN 450000 2024-08-03 700000 500000 500000 NaN 512500 2024-08-04 0 375000 375000 NaN 384375

Explain the difference between min_periods=1 and the default: the first gives numbers from day one, but they are based on an incomplete window and are misleading. In an official report the strict version is usually more correct. ewm is an exponential average that gives more weight to recent data.

6. Latest record per group in pandas

▾Intermediate
# Approach 1: sort and drop_duplicates — the most readable
latest = (df.sort_values(["customer_id", "order_date", "order_id"])
            .drop_duplicates(subset="customer_id", keep="last"))

# Approach 2: idxmax — when a single column decides
latest2 = df.loc[df.groupby("customer_id")["order_date"].idxmax()]

# Approach 3: with rank, when you have several tie-breakers
r = df.sort_values(["order_date", "order_id"], ascending=False)
latest3 = r[r.groupby("customer_id").cumcount() == 0]
print(latest[["customer_id", "order_id", "order_date", "amount"]].head(3))
customer_id order_id order_date amount 12 101 9310 2024-07-21 890000 27 102 9288 2024-07-19 1250000
The idxmax approach raises an error if the deciding column has NaN values, and on ties it returns the first without letting you give a tie-breaker. For real data the first approach is safer.

8. Remove duplicate records in pandas

▾Intermediate
before = len(df)

clean = (df.sort_values(["email", "created_at"])
           .drop_duplicates(subset="email", keep="last")     # keep the newest
           .reset_index(drop=True))

removed = before - len(clean)
print(f"{removed} rows removed ({removed/before:.2%})")

# Report what was removed before throwing it away
dropped = df.loc[~df.index.isin(clean.index)]
dropped.to_csv("removed_duplicates.csv", index=False, encoding="utf-8-sig")

# Normalize before comparing, otherwise " Ali@X.com " isn't detected as a duplicate
df["email_key"] = df["email"].str.strip().str.lower()
37 rows removed (0.74%)

Two good habits: always normalize the comparison key before removing (whitespace, letter case, Persian and Arabic character variants), and save the removed rows. encoding="utf-8-sig" is there so Excel opens non-ASCII text correctly.

9. Find missing records in pandas

▾Intermediate
# a) Customers without orders
no_orders = customers[~customers.customer_id.isin(orders.customer_id)]

# b) Days without sales
cal = pd.date_range("2024-08-01", "2024-08-31", freq="D")
missing_days = cal.difference(pd.to_datetime(orders.order_date.dt.date.unique()))

# c) Gaps in an id sequence
ids = invoices["id"].sort_values()
gaps = ids[ids.diff() > 1]
for end in gaps:
    prev = ids[ids < end].max()
    print(f"Gap: {prev+1} to {end-1}  ({end-prev-1} records)")

# d) Two-way differences between two sources
cmp = src.merge(tgt, on="id", how="outer", indicator=True)
print(cmp["_merge"].value_counts())
Gap: 10345 to 10347 (3 records) both 49813 left_only 187 right_only 4 Name: _merge, dtype: int64

The indicator=True argument is the direct equivalent of FULL JOIN ... IS NULL in SQL and is unbeatable for pipeline reconciliation: one line tells you how many records are unmatched on each side.

Detect outliers with the median and the median absolute deviation.

▾Intermediate
import numpy as np

def mad_outliers(s, thresh=3.5):
    """Modified Z method: robust to the outliers themselves."""
    med = s.median()
    mad = (s - med).abs().median()
    if mad == 0:
        return pd.Series(False, index=s.index)
    z = 0.6745 * (s - med) / mad
    return z.abs() > thresh

flag = mad_outliers(df["amount"])
print(f"{flag.sum()} outliers ({flag.mean():.2%})")
print(df.loc[flag, "amount"].describe()[["min", "max"]])
204 outliers (0.41%) min 4200000.0 max 12400000.0 Name: amount, dtype: float64

The advantage over a regular Z-score: the mean and standard deviation are themselves affected by outliers, but the median and MAD are not. On skewed data such as purchase amounts, the difference in results is dramatic.

7. Consecutive dates and the longest streak in pandas

▾Advanced
d = (df[["user_id", "event_time"]]
       .assign(d=lambda x: x.event_time.dt.normalize())
       .drop_duplicates(["user_id", "d"])
       .sort_values(["user_id", "d"]))

# The same SQL trick: date minus row number stays constant within a streak
d["grp"] = d["d"] - pd.to_timedelta(d.groupby("user_id").cumcount(), unit="D")

streaks = (d.groupby(["user_id", "grp"])
             .agg(start=("d", "min"), end=("d", "max"), streak=("d", "size"))
             .reset_index(drop=False)
             .drop(columns="grp"))

print(streaks[streaks.streak >= 3].sort_values("streak", ascending=False).head())
user_id start end streak 3 771 2024-08-04 2024-08-10 7 9 902 2024-08-12 2024-08-14 3

A more readable alternative for the same job: (d.groupby("user_id")["d"].diff() != pd.Timedelta("1D")).cumsum(), which builds the group id from “breaks in continuity”. Both are correct; the second states the code's intent more clearly.

10. Walking the employee hierarchy in Python

▾Advanced
from collections import defaultdict, deque

emps = {r.emp_id: r for r in df.itertuples()}
children = defaultdict(list)
for e in emps.values():
    children[e.manager_id].append(e.emp_id)

def walk(root=None, lvl=1, path=(), seen=None):
    """Depth-first traversal with protection against cycles."""
    seen = seen or set()
    for eid in children.get(root, []):
        if eid in seen:
            continue
        seen.add(eid)
        e = emps[eid]
        yield {"emp_id": eid, "name": e.name, "level": lvl,
               "path": " › ".join(path + (e.name,))}
        yield from walk(eid, lvl + 1, path + (e.name,), seen)

tree = pd.DataFrame(walk())
print(tree.head())

# Total number of reports under each manager
def count_all(eid):
    n, q = 0, deque(children.get(eid, []))
    while q:
        n += 1
        q.extend(children.get(q.popleft(), []))
    return n

tree["total_reports"] = tree.emp_id.map(count_all)
emp_id name level path 0 1 CEO 1 CEO 1 2 CTO 2 CEO › CTO 2 5 Data analyst 3 CEO › CTO › Data analyst

Building the children dictionary once brings the traversal down from O(n²) to O(n). The seen set is the equivalent of the path array in the SQL version and prevents an infinite loop on faulty data.

Write a validation function that checks the contract of the incoming data.

▾Advanced
from dataclasses import dataclass, field

@dataclass
class Contract:
    required: dict                      # column name -> type
    non_null: list = field(default_factory=list)
    unique:   list = field(default_factory=list)
    ranges:   dict = field(default_factory=dict)

def validate(df, c: Contract):
    errors = []
    missing = set(c.required) - set(df.columns)
    if missing:
        errors.append(f"Missing columns: {sorted(missing)}")
    for col, typ in c.required.items():
        if col in df and not str(df[col].dtype).startswith(typ):
            errors.append(f"Type of {col}: {df[col].dtype} (expected {typ})")
    for col in c.non_null:
        n = df[col].isna().sum()
        if n: errors.append(f"{n} empty values in {col}")
    for col in c.unique:
        n = df[col].duplicated().sum()
        if n: errors.append(f"{n} duplicate values in {col}")
    for col, (lo, hi) in c.ranges.items():
        n = int(((df[col] < lo) | (df[col] > hi)).sum())
        if n: errors.append(f"{n} out-of-range values in {col}")
    return errors

c = Contract(required={"order_id": "int", "amount": "float"},
             non_null=["order_id", "amount"], unique=["order_id"],
             ranges={"amount": (0, 5e8)})
issues = validate(df, c)
print(issues or ["No issues"])
['213 empty values in amount', '4 out-of-range values in amount']

Put this function at the end of the extract step and stop the pipeline if it returns any errors. If you work with ready-made tools, the equivalents are pandera, Great Expectations or dbt tests — naming them in an interview earns points.

Excel

In data and BI roles, Excel is still the common language with business stakeholders. This section starts with lookups and PivotTables and continues through dynamic arrays, the LET and LAMBDA functions, the data model and the link with Power Query.

Excel review sheet

The functions most likely to be asked about.EXCEL
=XLOOKUP(key, lookup_arr, return_arr, "Not found", 0)
Modern lookup; replaces VLOOKUP and INDEX/MATCH
=INDEX(C:C, MATCH(key, A:A, 0))
Lookup in older versions; the return column can be left of the key
=SUMIFS(sum_rng, rng1, c1, rng2, c2)
Multi-condition sum; sum range first, then the conditions
=COUNTIFS(...) · =AVERAGEIFS(...)
Multi-condition count and average
=SUMPRODUCT((A:A="x")*(B:B>10)*C:C)
Complex conditions and array multiplication
=IFERROR(expr, "-")
Hiding errors in the final report
=FILTER(rng, (A:A="x")*(B:B>10), "Empty")
Dynamic filter; the result spills
=UNIQUE() · =SORT() · =SORTBY()
Unique list and dynamic sorting
=SEQUENCE(n) · =RANDARRAY(n)
Generating sequences and random numbers
=TEXTSPLIT(A2, ",") · =TEXTBEFORE/AFTER
Splitting text without Text to Columns
=LET(x, expr, y, expr2, result)
Defining variables inside a formula; readable and faster
=LAMBDA(a, b, a/b) with Name Manager
Building a custom function without VBA
$A$1 · A$1 · $A1
Fully absolute / fixed row / fixed column — the F4 key
Table[#All] · Table[@Column]
Structured references in an Excel table
=EOMONTH(d, 0) · =EDATE(d, -1)
End of month and shifting by months
=NETWORKDAYS(a, b, holidays)
Counting working days
Ctrl+T · Alt+= · Ctrl+Shift+L
Convert to table / AutoSum / filter
Ctrl+Shift+↓ · Ctrl+' · F4
Select to the end / copy formula from above / repeat last action

Basic level 17

What is the difference between relative, absolute and mixed references?

▾Basic
A1      relative: shifts when copied in any direction
$A$1    absolute: never changes
A$1     fixed row, variable column
$A1     fixed column, variable row
=B2*$F$1

The classic use of mixed references is a two-dimensional multiplication table: you write one formula and drag it across the whole table. The F4 key cycles through the four modes.

How do VLOOKUP and XLOOKUP differ?

▾Basic
=VLOOKUP(A2, Data!A:D, 4, FALSE)
=XLOOKUP(A2, Data!A:A, Data!D:D, "Not found", 0)
VLOOKUPXLOOKUP
Lookup directionOnly to the right of the keyAny direction
Column numberManual; breaks when a column is insertedNot needed
Default modeApproximate matchExact match
Fallback value for errorsNoThe fourth argument
Forgetting FALSE in VLOOKUP is Excel's most dangerous mistake: it returns a wrong result without any error, because an approximate match is performed.

Build a conditional report with SUMIFS and COUNTIFS.

▾Basic
=SUMIFS(Sales[Amount], Sales[City], "Tehran", Sales[Date], ">="&$H$1, Sales[Date], "<"&$H$2)
=COUNTIFS(Sales[City], $G2, Sales[Status], "paid")
=AVERAGEIFS(Sales[Amount], Sales[City], $G2)
Tehran 183,000,000 Mashhad 58,000,000

Comparison conditions must be strings joined to the cell value with &: ">="&$H$1. Writing ">=$H$1" doesn't work.

How do you find and remove duplicate data?

▾Basic
=COUNTIFS($A$2:$A$1000, A2)>1          flag duplicates
=COUNTIFS($A$2:A2, A2)                 occurrence number (first = 1)
=UNIQUE(A2:A1000)                      dynamic unique list

On the ribbon: Data → Remove Duplicates, and to see them before removing: Home → Conditional Formatting → Highlight Duplicate Values.

The second formula is a good trick: the half-locked range $A$2:A2 grows as you drag, so the first occurrence gets 1 and the second gets 2, and you can remove only the rows above 1.

Which text functions are most useful for data cleaning?

▾Basic
=TRIM(CLEAN(A2))                      remove spaces and control characters
=PROPER(A2) · =UPPER(A2) · =LOWER(A2)
=LEFT(A2, 3) · =RIGHT(A2, 4) · =MID(A2, 5, 3)
=SUBSTITUTE(A2, "-", "")
=TEXTBEFORE(A2, "@") · =TEXTAFTER(A2, "@")
=VALUE(SUBSTITUTE(A2, ",", ""))       numeric text to a number
" Ali Reza " → "Ali Reza" "user@site.com" → "user" and "site.com"

In Persian data, the Arabic letters “ي” and “ك” cause trouble. Unify them with two nested SUBSTITUTE calls, otherwise lookups fail silently.

Name the common Excel errors and say what each one means.

▾Basic
ErrorMeaningFix
#N/AValue not foundIFNA, or check the data type and extra spaces
#REF!Reference to a deleted cellRewrite the formula
#VALUE!Unsuitable data typeCheck that the input is a number
#DIV/0!Division by zeroIFERROR or a condition on the denominator
#NAME?Unknown function or range nameSpelling and Name Manager
#SPILL!The array's spill range is occupiedClear the cells below

In a report that goes to a manager, hide errors with IFERROR; but keep them in your own working sheet — an error is information.

What advantage does an Excel table (Ctrl+T) have over a plain range?

▾Basic
  • As rows are added, the range grows automatically and PivotTables and charts stay up to date.
  • Structured references become readable: Sales[Amount] instead of C2:C5000.
  • A formula in a calculated column is automatically extended to every row.
  • Power Query and the data model work directly on the table.
=SUMIFS(Sales[Amount], Sales[City], [@City])

This is the first thing to do with any raw file. If you press Ctrl+T right away in a practical interview, you score points.

How is a formula written in Excel?

▾Basic
=A2+B2                  sum of two cells
=SUM(A2:A100)           sum of a range
=A2*1.09                multiply by a constant
=AVERAGE(B2:B100)       average
=MAX(C:C)-MIN(C:C)      range of values

Every formula starts with an equals sign. A range is written with a colon (A2:A100) and arguments are separated by commas or semicolons — depending on the system's regional settings.

If a formula you copied doesn't work, the first thing to check is this argument separator. In Persian and many European locales it is usually a semicolon, not a comma.

Write a simple SUMIF and COUNTIF.

▾Basic
=SUMIF(B:B, "Tehran", C:C)        sum of the amounts of Tehran rows
=COUNTIF(B:B, "Tehran")           number of Tehran orders
=COUNTIF(C:C, ">500000")          number of orders above 500 thousand
=AVERAGEIF(B:B, "Tehran", C:C)    average Tehran amount
=COUNTIF(A:A, A2)>1               is this value a duplicate?
183,000,000 1,120 842 163,392 FALSE

The difference from the “IFS” versions: in SUMIF the criteria range comes first and the sum range last, but in SUMIFS the sum range comes first. Swapping this order is the most frequent formula-writing mistake in Excel.

Write a simple condition with IF.

▾Basic
=IF(C2>500000, "large", "small")
=IF(AND(C2>500000, B2="Tehran"), "target", "normal")
=IF(OR(D2="paid", D2="shipped"), "valid", "review")
=IFS(C2>=1000000,"large", C2>=300000,"medium", TRUE,"small")
large target valid medium

Use IFS instead of multi-layer nested IFs; it is more readable, and a final condition of TRUE plays the role of “otherwise”. In older versions of Excel, CHOOSE or a lookup table is a better replacement for nested IFs.

Build a simple PivotTable in a few steps.

▾Basic
  1. Click in the data and press Ctrl+T to turn it into a table.
  2. On the Insert tab, choose PivotTable and pick where to place it.
  3. Drag the “City” field to Rows.
  4. Drag the “Amount” field to Values; it is summed by default.
  5. Drag the “Month” field to Columns to make it a two-dimensional table.
  6. Right-click a number and set the number format in Value Field Settings.
January February Total Tehran 50,000,000 62,000,000 112,000,000 Mashhad 18,000,000 21,000,000 39,000,000 Total 68,000,000 83,000,000 151,000,000

If Values shows Count instead of Sum, the amount column is text, not numbers. The cause is usually a thousands separator or extra spaces in the data, which must be cleaned first.

Set up number and date formatting.

▾Basic
#,##0                    number with a thousands separator
#,##0 "IRR"              with a unit
0.0%                     percentage with one decimal place
[>=1000000]#,##0,,"M";#,##0    conditional millions
yyyy-mm-dd               Gregorian date
[Red]-#,##0;[Black]#,##0 negatives in red

Formatting only changes the appearance; the cell's actual value stays untouched. If you want the number really rounded (for example to avoid accumulated error in sums), you must use the ROUND function, not formatting.

Which keyboard shortcuts actually save time?

▾Basic
ShortcutAction
Ctrl+TConvert a range to a table
Ctrl+Shift+↓Select to the end of the column
Ctrl+↓ / Ctrl+←Jump to the end of the data block
Alt+=Insert AutoSum
Ctrl+Shift+LToggle filters on and off
F4Cycle between absolute and relative reference modes
Ctrl+D / Ctrl+RCopy from the cell above / to the left
Ctrl+Shift+VPaste Special
Alt+H+O+IAuto-fit column width
Ctrl+`Show formulas instead of results

The last one is superb for reviewing other people's files: one key shows every formula, and you immediately see where a constant was typed instead of a formula — a common source of errors in financial files.

What is the difference between COUNT, COUNTA and COUNTBLANK?

▾Basic
=COUNT(A2:A100)        numeric cells only
=COUNTA(A2:A100)       any non-empty cell, including text and errors
=COUNTBLANK(A2:A100)   empty cells only
=ROWS(A2:A100)         total number of cells, regardless of content
84 97 3 99

The difference between COUNTA and ROWS is a good data quality gauge: the gap between them is the number of empty cells. There is also a subtle point — a cell whose formula returns an empty string counts as filled for COUNTA but also as empty for COUNTBLANK.

What is a named range and how does it help?

▾Basic
Instead of:  =SUM(Sheet2!$C$2:$C$5000)
write:       =SUM(SalesAmount)

Dynamic definition in Name Manager:
SalesAmount   =Sales[Amount]
TaxRate       =0.09
ReportDate    =TODAY()

The main benefit of naming is readability and keeping changes in one place. If the tax rate changes, you fix it in one spot, not in thirty formulas. For constants too, always create a name or an input cell instead of typing them directly into formulas.

How do Freeze Panes and Split differ?

▾Basic
  • Freeze Top Row freezes only the first row.
  • Freeze First Column freezes only the first column.
  • Freeze Panes freezes everything above and to the left of the selected cell; so if you want the first row and first column frozen together, select the second cell of the second row.
  • Split divides the window into two or four independent panes with separate scrolling.

In long files, freezing the header is the first thing to do. If you've created an Excel table (Ctrl+T), the column names automatically replace the column letters while you scroll, and Freeze isn't needed.

Where do you use Flash Fill and Text to Columns?

▾Basic
  • Text to Columns (Data tab): splits by a delimiter or a fixed width. It is also useful for converting text to dates or numbers.
  • Flash Fill (Ctrl+E): type one or two examples by hand, Excel guesses the pattern and fills in the rest.
Input Manual example Flash Fill result Maryam Ahmadi M. Ahmadi K. Rezaei Kaveh Rezaei S. Mousavi
Both produce static results, not formulas. If the source data changes, nothing updates. For repeated work, Power Query is the right choice, not Flash Fill.

Intermediate level 13

Write INDEX/MATCH and say why it still matters.

▾Intermediate
=INDEX(Data[Amount], MATCH(A2, Data[OrderID], 0))

Two-dimensional lookup (row and column):
=INDEX($B$2:$F$50, MATCH($A2, $A$2:$A$50, 0), MATCH(B$1, $B$1:$F$1, 0))

Organizations still on Excel 2016 don't have XLOOKUP. Also, INDEX/MATCH reads only the column it needs, so on large files it is lighter than a VLOOKUP over the whole table.

What is a PivotTable, and how do you set one up for a sales report?

▾Intermediate
  • Rows: city — Columns: month — Values: sum of amount — Filters: order status.
  • Value Field Settings to change the aggregate function and the number format.
  • Show Values As → % of Column Total for each city's share.
  • Grouping dates into months and quarters by right-clicking the date field.
  • Slicers and Timelines for interactive filtering, and connecting several pivots to one filter with Report Connections.
If a pivot's source is a fixed range, it doesn't update when data is added. Always make the source a table or a Power Query query, then hit Refresh.

Where is SUMPRODUCT better than SUMIFS?

▾Intermediate
Weighted sum: price × quantity
=SUMPRODUCT(Qty, Price)

Conditions that SUMIFS can't handle:
=SUMPRODUCT((MONTH(Date)=8)*(City="Tehran")*Amount)

Counting unique rows with a condition:
=SUMPRODUCT((City="Tehran")/COUNTIFS(Cust, Cust&"", City, "Tehran"))
2,410,000 183,000,000 612

SUMPRODUCT can put conditions on the results of functions (like MONTH), whereas SUMIFS only works on the raw column values. The cost is that it gets slow on very large data.

Show dynamic arrays with a real report.

▾Intermediate
Sorted list of unique cities:
=SORT(UNIQUE(Sales[City]))

Sales for each city next to it:
=SUMIFS(Sales[Amount], Sales[City], H2#)

The ten largest orders of the current month:
=TAKE(SORT(FILTER(Sales, (Sales[Month]=$B$1)*(Sales[Status]="paid")), 4, -1), 10)
Isfahan 41,000,000 Mashhad 58,000,000 Tehran 183,000,000

The # after an address (as in H2#) means “the whole spilled range”. With it, your report grows and shrinks automatically as the data changes, and you no longer need to drag formulas again.

Where do you use Data Validation and named ranges?

▾Intermediate
Drop-down list from a dynamic range:
Data → Validation → List → =CityList

Dynamic name definition in Name Manager:
CityList  =SORT(UNIQUE(Sales[City]))

Dependent conditional formula:
=IF(COUNTIF(CityList, A2)=0, "Invalid city", "")

Manual input is the biggest source of dirty data. Drop-down lists and validation move cleaning from the end of the process to its start.

Highlight problem rows with conditional formatting.

▾Intermediate
Formula-based rule, applied to the whole row:
=AND($D2>10000000, $E2="pending")

Highlighting overdue items:
=AND($F2<TODAY(), $G2<>"paid")

The key point: lock the column with $ but leave the row free so the rule applies correctly across the whole row. If you lock both, every row is colored based on a single cell.

Show date and working-day calculations.

▾Intermediate
=EOMONTH(A2, 0)                 end of the current month
=EOMONTH(A2, -1)+1              start of the current month
=EDATE(A2, 3)                   three months later
=NETWORKDAYS(A2, B2, Holidays)  working days between two dates
=WORKDAY(A2, 5, Holidays)       five working days later
=DATEDIF(A2, TODAY(), "m")      difference in months
2024-08-31 2024-08-01 2024-11-30 21 1404-06-15 (after applying a Persian calendar format)

Local public holidays (Iran's, for example) don't match the default calendar; you need to put the list of holidays in a named range and pass it as the third argument.

The Excel file has become heavy and slow. What do you do?

▾Intermediate
  1. Reduce volatile functions (NOW, TODAY, OFFSET, INDIRECT, RAND); they recalculate the whole sheet on every change.
  2. Replace whole-column references (A:A) with a table or an exact range.
  3. Replace heavy repeated formulas with a single computed helper column.
  4. Paste Values for static data.
  5. Limit conditional formatting on huge ranges.
  6. If the data source is large, move the processing to Power Query and the aggregation to the data model.

The last point is the senior-level answer: Excel is a calculation tool, not a database. Moving heavy logic to Power Query or Power Pivot improves both speed and maintainability.

What tools does Excel have for What-If analysis?

▾Intermediate
  • Goal Seek: finds the value of one input that makes the output hit a target number.
  • Data Table: shows the output's sensitivity to one or two variables as a table.
  • Scenario Manager: saves and compares several sets of assumptions (pessimistic, base, optimistic).
  • Solver: optimization with an objective function and constraints; for allocation problems and linear programming.

If you have a background in optimization or operations research, Solver is a good bridge: you can say you've solved the same problem at larger scale with Python and optimization libraries.

Build a dynamic range with INDIRECT and OFFSET, and say what the risk is.

▾Intermediate
Reference to a sheet chosen in a cell:
=SUM(INDIRECT("'"&$B$1&"'!C:C"))

Dynamic range based on the number of rows:
=OFFSET($A$1, 1, 0, COUNTA($A:$A)-1, 1)

The modern, better alternative:
=SUM(Sales[Amount])       Excel table, grows automatically
Both functions are volatile: they recalculate on every change anywhere in the file and cause noticeable slowness in large files. On top of that, INDIRECT builds its reference from a string, so if a sheet is renamed Excel doesn't notice and the formula silently returns an error.

Scenario: you have two customer files that need to be reconciled. How do you proceed?

▾Intermediate
1) Normalize the match key
=TRIM(LOWER(SUBSTITUTE(SUBSTITUTE(A2,"ي","ی"),"ك","ک")))

2) In each file, check whether the key exists in the other file
=IF(COUNTIF(FileB!$A:$A, $A2)=0, "Only in A", "In both")

3) Find value differences
=LET(v, XLOOKUP($A2, FileB!$A:$A, FileB!$D:$D, ""),
     IF(v="", "missing", IF(ABS(v-$D2)<1, "same", v-$D2)))

4) Summary of the result
=COUNTIF($F:$F,"Only in A") & " unmatched records"
In both 4,812 Only in A 37 Amount differs 11

Never skip the first step. In Persian data, extra spaces and the Arabic forms of “ی” and “ک” account for most of the “no match” cases between records that are actually the same.

How do you set up sheet protection and user inputs?

▾Intermediate
  1. Select all cells and untick Locked in Format Cells.
  2. Select only the cells with formulas and lock them again.
  3. On the Review tab, apply Protect Sheet with a password and allow selecting locked cells.
  4. Hide sensitive formulas with the Hidden option so they don't show in the formula bar.
  5. Lock the file structure against deleting or renaming sheets with Protect Workbook.
This protects against user mistakes, not against someone deliberately trying to get around it; an Excel sheet password is easy to break. For truly sensitive data, don't put it in the file at all.

Build a simple financial model: a cash-flow forecast with scenarios.

▾Intermediate
Inputs (blue cells, separate from the calculations):
  GrowthRate   B2   3%
  Churn        B3   5%
  ARPU         B4   250,000

Calculation, column by column for each month:
Customers:  =ROUND(D5*(1+$B$2)*(1-$B$3), 0)
Revenue:    =D6*$B$4
CumCash:    =C8+D7-D9

Sensitivity analysis with a Data Table:
Data → What-If Analysis → Data Table
Row: growth rate    Column: churn rate    Result cell: first-year profit
2% 3% 4% 5% 3% 1.82B 2.14B 2.48B 2.85B 5% 1.21B 1.48B 1.77B 2.09B 7% 0.64B 0.86B 1.10B 1.36B

The golden rule of financial modeling: never write a constant inside a formula. All assumptions should sit in a clearly marked, colored input area so anyone can change the scenario without touching the formulas.

Advanced level 11

Make a complex formula readable and fast with LET.

▾Advanced
Before: the same calculation three times
=IF(SUMIFS(Amt,City,A2)>0, SUMIFS(Amt,City,A2)/COUNTIFS(City,A2), 0)

After:
=LET(
   total, SUMIFS(Amt, City, A2),
   n,     COUNTIFS(City, A2),
   IF(n=0, 0, total/n)
 )
163,392

Each variable in LET is calculated only once. On large files, rewriting repetitive formulas with LET sometimes gives a several-fold speedup, and more importantly, the formula is still readable six months later.

Build a custom function with LAMBDA.

▾Advanced
In Name Manager, define the name SafeDiv:
=LAMBDA(a, b, IF(OR(b=0, ISBLANK(b)), 0, a/b))

Then in the worksheet:
=SafeDiv(D2, E2)

A recursive example: stripping unwanted characters
=LAMBDA(txt, chars,
   IF(chars="", txt,
      CleanChars(SUBSTITUTE(txt, LEFT(chars,1), ""), MID(chars,2,99))))

LAMBDA centralizes repeated logic in one place without polluting the file with VBA and macros (which many organizations block for security reasons). This is a senior-level answer.

What do the array functions BYROW, MAP and REDUCE do?

▾Advanced
Sum of each row without dragging a formula:
=BYROW(B2:E100, LAMBDA(r, SUM(r)))

Apply a function to each element:
=MAP(A2:A100, LAMBDA(x, IF(x>1000000, "large", "small")))

Running total:
=SCAN(0, B2:B100, LAMBDA(acc, x, acc+x))

Custom aggregation:
=REDUCE(0, B2:B100, LAMBDA(acc, x, acc + IF(x>0, x, 0)))
{1,240,000; 980,000; 1,510,000; ...}

These functions bring Excel closer to a functional programming style. If you know Python, mention how they map to map, accumulate and reduce.

What do Power Pivot and the data model make possible in Excel?

▾Advanced
  • Loading several tables and creating relationships between them, without VLOOKUP and without merging them by hand.
  • Getting past the roughly one-million-row worksheet limit; the columnar compression engine holds tens of millions of rows.
  • Writing measures in DAX and using them in PivotTables.
  • Building a date table and enabling time intelligence functions.
Total Sales = SUM(Sales[Amount])
Sales LY = CALCULATE([Total Sales], SAMEPERIODLASTYEAR('Date'[Date]))

This is the same engine that runs under Power BI. If you say “I build a star schema and define measures instead of chained formulas”, you've shown the line between an Excel user and a BI analyst.

How is a professional Excel dashboard built?

▾Advanced

A three-layer structure that always works:

  1. Data layer: raw sheets or Power Query queries; untouched and free of decorative formulas.
  2. Calculation layer: intermediate tables, measures, helper columns; hidden from the end user.
  3. Presentation layer: only charts, KPIs and slicers; no complex formulas.

Practical tips: turn off gridlines, limit the color palette to two main colors, give each chart one message, and put three to five KPIs with period comparisons at the top of the page.

If you have a work sample, explain this three-layer structure. More than looks, the interviewer is after maintainability and automatic refresh.

Practical exercise: build a monthly report from two files, sales and customers.

▾Advanced

The approach you demonstrate in a practical interview:

  1. Import both files with Power Query (Data → Get Data), not by copy and paste.
  2. Set the column types and remove blank and duplicate rows.
  3. Merge Queries on customer_id as a Left Outer join; compare the row counts before and after.
  4. Build a month column and load to the Data Model.
  5. A PivotTable on the model, with the following measure:
Revenue = SUM(Sales[Amount])
Revenue LM = CALCULATE([Revenue], PREVIOUSMONTH('Date'[Date]))
MoM % = DIVIDE([Revenue] - [Revenue LM], [Revenue LM])

The key sentence while you work: “I compare the row counts before and after the merge to make sure the relationship was one-to-many and no rows were multiplied.”

When do you migrate from Excel to Power BI or SQL?

▾Advanced

Clear signs it's time to migrate:

  • The data has passed a few hundred thousand rows and the file has become slow.
  • The same report is built by hand every week.
  • Several different versions of one file circulate in the organization and the numbers don't match.
  • Row-level or user-level access control has become necessary.
  • The data source keeps changing and automatic refresh is needed.

A balanced answer matters: don't make Excel look worthless. Say it is still the best tool for quick exploratory analysis and ad hoc modeling, but recurring, shared reports should move to a centralized tool.

Combine dynamic array functions to build a complete report.

▾Advanced
A report built with a single formula:
=LET(
   src,   FILTER(Sales, (Sales[Date]>=$B$1)*(Sales[Date]<=$B$2)*(Sales[Status]="paid")),
   cities, SORT(UNIQUE(CHOOSECOLS(src,3))),
   rev,    BYROW(cities, LAMBDA(c, SUMIFS(Sales[Amount], Sales[City], c))),
   ord,    BYROW(cities, LAMBDA(c, COUNTIFS(Sales[City], c))),
   HSTACK(cities, rev, ord, rev/ord)
 )
Tehran 183,000,000 1,120 163,392 Mashhad 58,000,000 402 144,278 Isfahan 41,000,000 295 138,983

One formula, one complete table — and it updates automatically when the dates in B1 and B2 change. HSTACK places columns side by side and CHOOSECOLS pulls the columns you want out of the FILTER result. These are only available in recent versions of Excel 365.

Explain rounding errors and decimal precision in Excel.

▾Advanced
=0.1+0.2-0.3            result: 5.55E-17, not zero
=IF(0.1+0.2=0.3, "yes", "no")            no
=IF(ROUND(0.1+0.2-0.3, 10)=0, "yes", "no")   yes

Safe comparison of two decimal numbers:
=IF(ABS(A2-B2)<0.005, "equal", "different")

Like other software, Excel uses binary floating point, and some decimal fractions have no exact representation. With formatting the number looks rounded, but the actual value is unchanged.

In financial reports this shows up as “the column total doesn't match the grand total”. The right fix is to round with ROUND at the step where the number is shown to the user — not the dangerous “Set precision as displayed” option, which changes the actual values permanently.

How do you prepare an Excel file for handing over to others?

▾Advanced
  1. A guide sheet at the front: the file's purpose, data source, refresh date, the definition of each metric and the owner's name.
  2. Separate the layers: raw data, calculations and presentation on separate sheets; hide the intermediate sheets.
  3. Conventional colors: inputs blue, formulas black, references to other sheets green.
  4. Remove leftovers: broken named ranges, conditional formatting over millions of cells, test sheets.
  5. Check for errors with Formulas → Error Checking and a search for #REF!.
  6. Return to cell A1 on every sheet and save with the first sheet active.
  7. Remove personal information via File → Info → Check for Issues.

The sixth point is small but important: a file where every sheet opens in the middle of the data on a random cell looks unprofessional without anyone quite knowing why.

Value inventory with the FIFO method — without VBA and without helper columns.

▾Advanced

You have a table of purchases in date order (each row a shipment with a quantity and a unit cost) and the total sales for the period. You need to say what is still in stock, what it is worth, and what the cost of goods sold was.

Purchases table:
  Date         Qty     UnitCost
  1403-01-10     100      50,000
  1403-02-04     150      54,000
  1403-03-19      80      61,000
  1403-04-22     120      58,000

Total sales for the period:  Sold = 260 units

The key idea. In FIFO, sales are taken from the oldest shipment first. If we call the running total of purchased quantities up to shipment i cum, the remainder of that shipment is exactly:

remaining_i = MIN( qty_i , MAX( 0 , cum_i − Sold ) )

Check the three cases: if cum_i ≤ Sold, the whole shipment has been used and it becomes zero; if cum_i − Sold ≥ qty_i, sales never reached this shipment and it stays whole; and the middle case is a partial cut of the shipment.

=LET(
   qty,    Purchases[Qty],
   cost,   Purchases[UnitCost],
   sold,   Sold,
   cum,    SCAN( 0, qty, LAMBDA(acc, v, acc + v) ),
   remain, MAP( qty, cum, LAMBDA(q, c, MIN( q, MAX(0, c - sold) )) ),
   used,   qty - remain,
   HSTACK(
     Purchases[Date], qty, cost, cum, used, remain,
     remain * cost
   )
 )
Date Qty UnitCost cum used remain value 1403-01-10 100 50,000 100 100 0 0 1403-02-04 150 54,000 250 150 0 0 1403-03-19 80 61,000 330 10 70 4,270,000 1403-04-22 120 58,000 450 0 120 6,960,000
Closing inventory value:
=SUMPRODUCT( remain, cost )                       →  11,230,000

Cost of goods sold (COGS):
=SUMPRODUCT( qty - remain, cost )                 →  13,610,000

Comparison with the weighted-average method:
=Sold * SUMPRODUCT(qty, cost) / SUM(qty)          →  14,391,111

Why this solution is good. It needs no helper columns, no loops and no macros. When a new shipment is added to the table, everything expands automatically. More importantly, it turns the FIFO logic into a mathematical expression that is both reviewable and portable to SQL or pandas with the same formula.

Two edge cases to raise yourself. First, if Sold exceeds total purchases, the formula gives a zero remainder but doesn't report the negative stock; add a check like IF(sold>SUM(qty), "Sales exceed stock", …). Second, this version assumes the rows are already sorted by date — if they aren't, you must apply SORTBY first, or the result will be silently wrong.

If the interviewer asks “what about LIFO?”, the answer is elegant: the same formula, just reverse the table — SORTBY(Purchases, Purchases[Date], -1) — because the logic of “consume from the top of the list” stays untouched. That one sentence shows you understood the solution rather than memorized it.

Power BI

Working with Power BI has three layers: data modeling, calculation with DAX, and publishing and maintenance. Calculations are covered in the separate DAX section; this section focuses on the model, relationships, choosing the right chart, performance and publishing reports.

Power BI review sheet

Terms you should be able to explain without pausing.TEXT
Power BI Desktop
Environment for building models and reports on Windows
Power BI Service
Cloud space for publishing, scheduling and sharing
On-premises Data Gateway
Bridge between the cloud service and on-premises data sources
Semantic Model (Dataset)
Data model + relationships + measures
Star Schema
Fact tables in the center, dimensions around them
Import / DirectQuery / Dual
Full import / live query / hybrid
VertiPaq
In-memory columnar engine with high compression
Row-Level Security (RLS)
Restricting rows by user
Incremental Refresh
Refreshing only the recent partitions
Dataflow
Power Query in the cloud, shareable across reports
Calculation Group
Applying one logic (such as YTD) to every measure
Field Parameter
Letting the user switch the metric or dimension
Drillthrough / Bookmark
Going to a details page / saving a view state
Performance Analyzer
The time of each visual and the DAX query behind it
DAX Studio · Tabular Editor
Query analysis and advanced model editing
Workspace → App
Team workspace and a published package for consumers

Basic level 17

Name the main components of Power BI and say what each one does.

▾Basic
  • Desktop: connecting to sources, transforming with Power Query, modeling and building reports.
  • Service: publishing, scheduled refresh, sharing, dashboards and alerts.
  • Gateway: gives the cloud service access to sources inside the organization's network.
  • Mobile: viewing on a phone with a dedicated layout.
  • Report Server: fully on-premises hosting for organizations that can't use the cloud.

Also know the difference between a report and a dashboard in the service: a report has multiple pages and is connected to one model; a dashboard is a single-page canvas of tiles from several different reports.

Why is a star schema preferred over a flat table?

▾Basic
          DimDate      DimCustomer
              \            /
               \          /
                FactSales
               /          \
        DimProduct       DimStore
  • The VertiPaq engine is optimized for this pattern and compresses it better.
  • Filters flow from the dimensions toward the fact; the filter logic stays simple and predictable.
  • One shared date table enables time intelligence for every metric.
  • A flat table repeats text values millions of times and inflates the model size.

If you want one key sentence: “In Power BI, getting the model right matters more than clever DAX. Most complicated DAX problems are rooted in a bad model.”

Explain the types of relationship cardinality.

▾Basic
TypeUseRisk
One-to-many (1:*)The standard case: dimension to factNo
One-to-one (1:1)One entity split across two tablesUsually they should be merged
Many-to-many (*:*)When neither side has a unique keyUnintuitive results and slowness

The right solution for many-to-many is to build a bridge table with unique values, not to switch on a direct many-to-many relationship. Be sure to say this.

What is the difference between a measure and a calculated column?

▾Basic
Calculated ColumnMeasure
When it is computedWhen the data is loadedWhen the visual is displayed
Stored in memoryYes, it increases the model sizeNo
Evaluation contextRow contextFilter context
Best forCategories, labels, sort keysAny aggregate number
-- Column: fixed for each row
Price Band = IF(Sales[Amount] > 1000000, "High", "Low")

-- Measure: recalculated for every filter
Total Sales = SUM(Sales[Amount])

Practical rule: anything that can be a measure should be a measure. Use a calculated column only when its value must be used on an axis, in a filter or in a slicer.

What is the path for building a Power BI report from scratch to publishing?

▾Basic
  1. Connect: Get Data and choose the source (SQL, Excel, folder, web).
  2. Transform: in Power Query, set column types, remove extra columns and clean up.
  3. Model: create relationships in Model view and add a date table.
  4. Calculate: write the main measures in DAX.
  5. Report: lay out the visuals, filters and slicers.
  6. Publish: publish to a workspace, set up scheduled refresh and build an app.

The order matters. Most of the problems people wrestle with at the report stage come from skipping the modeling stage.

How do you create a relationship between two tables?

▾Basic

In Model view, drag the key column of the dimension table onto the matching column in the fact table. Power BI usually detects the cardinality itself, but always check it.

Customer[CustomerID] 1 ────► * Sales[CustomerID] Filter direction: single (from Customer to Sales)
  • The “one” side must have unique, non-blank values.
  • The data types of the two columns must match, or the relationship can't be created.
  • Only one relationship between two tables can be active.

If Power BI won't let you create a one-to-many relationship, the column on the “one” side has duplicates. This usually means the dimension table wasn't built properly and unique values need to be extracted first.

Which visual suits which question?

▾Basic
QuestionVisual
Trend over timeLine chart
Comparing categoriesBar or column chart
A single key numberCard or KPI
Row-level detailsTable or matrix
Relationship between two numeric variablesScatter chart
Share of a total (at most 4 parts)Donut, with care
Geographic distributionMap or filled map
Breaking down a changeWaterfall

Avoid pie charts as much as possible: the human eye is poor at comparing angles. The same data in a horizontal bar chart is read faster and more accurately.

What is a slicer and how does it affect several visuals?

▾Basic

A slicer is a visual filter on the page that the user works with directly. By default it affects every visual on the same page.

  • With Edit Interactions you can specify which visuals should not be affected.
  • With Sync Slicers the same slicer is kept in sync across several pages.
  • For dates, use the Between or Relative Date mode.

Always give the date slicer a sensible default, for example the last three months. Without it, the user opening the report waits for a scan of the entire history.

What is the difference between visual-level, page-level and report-level filters?

▾Basic
LevelScopeExample use
VisualThat visual onlyShowing the top 10 products in one chart
PageEvery visual on the pageA page dedicated to one product category
ReportEvery pageExcluding test orders from the whole report
DrillthroughTarget page, based on the user's selectionDetails of one specific customer

A report-level filter is ideal for fixed rules such as is_test = false, but if the rule is needed in every report, it belongs in the model or data source layer, not in a report filter.

What is a hierarchy in Power BI and how do you build one?

▾Basic

In Model view, right-click a column and choose Create Hierarchy, then drag the next columns into it.

Geography:  Country › Province › City › Store
Date:       Year › Quarter › Month › Day
Product:    Category › Subcategory › Product

In a visual, the user can drill down level by level with the drill buttons, or use Expand to see all levels side by side.

Text columns such as the month name must be sorted by their matching numeric column (Sort by Column), otherwise the months appear alphabetically — a mistake almost every beginner makes once.

What is the difference between a dashboard, a report and an app in the service?

▾Basic
ReportDashboardApp
Number of pagesMulti-pageOne canvasA collection of reports
SourceOne semantic modelTiles from several reportsThe content of a workspace
InteractionFull filtering and drillLimited, click on a tileLike a report
AlertsNoOn tiles—
AudienceAnalystManagerEnd consumer

Dashboards exist only in the cloud service and can't be built in Desktop. If someone says “build a dashboard”, they usually mean a report page, not a real Dashboard — clarify exactly this.

We want to show the sales trend over the last 24 months. Which chart?

▾Basic

A line chart with a time axis.

  • The human eye reads slope better than the heights of consecutive bars, so for a “trend” a line is best.
  • If the data is very noisy, add a seven-day or three-month moving-average line as well.
  • Start the vertical axis at zero unless the changes are small and important — and if you don't, say so explicitly in the title.
  • Don't put more than four lines in one chart; for more, use small multiples side by side.
If the time range is irregular (for example only months that had sales), the line fills the gap with a false slope. Build a complete date table so months without data become zero or the real gap is visible.

We want to compare the sales of 12 cities. Which chart?

▾Basic

A horizontal bar chart, sorted from highest to lowest.

  • Horizontal, because category names are often long, and with vertical bars the labels rotate and stop being readable.
  • Descending order makes the ranking visible without reading the numbers.
  • If there are many categories, show the top ten and group the rest under “Other”.
  • Put data labels on the bars so the axis and gridlines aren't needed.

For comparing quantities, bar length is the most accurate visual encoding — more accurate than angle (pie), area (bubble) and color (heat map). Remember this accuracy order; it underlies most chart decisions.

We want to show the share of four sales channels in the total. Which chart?

▾Basic

A 100% stacked bar chart, or a plain bar chart with percentages. A pie chart only if there are two or three parts with large differences.

SituationRecommendation
Share at one point in time, 2 to 3 partsPie or donut is acceptable
Share at one point in time, more than 3 partsBar chart with percentage labels
Change in share over time100% stacked bar or area chart
Share with a hierarchyTreemap

The problem with pie charts is that comparing angles is hard: two slices of 23 and 26 percent look practically the same. With the same data in a horizontal bar chart, the difference is visible immediately.

We want to examine the relationship between discount and sales volume. Which chart?

▾Basic

A scatter plot with discount on the horizontal axis and volume on the vertical axis.

  • Each point is a product or an order.
  • Point size can show a third dimension, for example revenue.
  • Point color for the product category.
  • Add a trend line from the Analytics pane.

If there are so many points that they overlap, reduce their opacity or use a density plot. And remember that a scatter plot shows correlation, not causation — write exactly this in the title or a caption.

We want to say revenue went from 10 to 14 billion and show the components of the change. Which chart?

▾Basic

A waterfall chart.

Start of period 10.0B + New customers +2.6B + Existing growth +1.9B − Smaller baskets −0.4B − Churn −0.1B End of period 14.0B

This chart answers exactly the question “what made up this change”, whereas two plain bars only say that a change happened.

In Power BI you can use the breakdown feature so it finds the biggest contributors itself. For financial reporting and budget variance analysis, this chart is unrivaled.

We want to show one key number compared with a target. Which chart?

▾Basic
  • The KPI visual: the current number, the target and the trend in one tile.
  • A card with a helper measure: a big number with a caption such as “12% higher than last month”.
  • Gauge: only when there is a clear target and range; it takes a lot of space and conveys little information.
  • Bullet chart (a custom visual): the most compact way to show a value, a target and ranges.
KPI Label =
VAR diff = DIVIDE ( [Total Sales] - [Sales PM], [Sales PM] )
RETURN FORMAT ( [Total Sales], "#,0" ) & "  (" &
       IF ( diff >= 0, "▲ ", "▼ " ) & FORMAT ( ABS(diff), "0.0%" ) & " vs last month)"

A number without a comparison is meaningless. Every card should have at least one reference point: the previous period, a target, or an average.

Intermediate level 17

Compare Import, DirectQuery and Composite.

▾Intermediate
ImportDirectQuery
Report speedVery fastDepends on the source
Data freshnessAs of the last refreshReal time
Data volumeLimited by capacityUnlimited
DAX capabilitiesFullLimited
Load on the sourceOnly during refreshWith every user interaction

Composite mode lets some tables be Import and some DirectQuery. Dual mode is used for dimension tables so they take on the right role depending on the query.

The right default answer: “Import, unless we have a real need for real-time data or a volume beyond capacity.”

What does cross-filter direction mean, and why is bidirectional dangerous?

▾Intermediate

In single direction, the filter flows from the “one” side to the “many” side; selecting a customer filters their sales, but not the other way round.

Bidirectional lets the filter flow back. Its problems:

  • Ambiguous filter paths in multi-table models and ambiguity errors.
  • Unintuitive results that are hard to debug.
  • A noticeable drop in performance.
  • Combined with RLS, it can leak data.
-- Instead of making it permanently bidirectional, case by case and under control:
Customers With Sales =
CALCULATE(
    DISTINCTCOUNT(Customer[CustomerID]),
    CROSSFILTER(Sales[CustomerID], Customer[CustomerID], BOTH)
)

This answer (“single direction by default, bidirectional only temporarily inside CALCULATE”) is a sign of mature modeling.

Why do you need a dedicated date table, and how do you build one?

▾Intermediate
Date =
VAR MinD = MIN(Sales[OrderDate])
VAR MaxD = MAX(Sales[OrderDate])
RETURN
ADDCOLUMNS(
    CALENDAR(DATE(YEAR(MinD),1,1), DATE(YEAR(MaxD),12,31)),
    "Year",      YEAR([Date]),
    "MonthNo",   MONTH([Date]),
    "MonthName", FORMAT([Date], "MMMM"),
    "Quarter",   "Q" & QUARTER([Date]),
    "YearMonth", FORMAT([Date], "YYYY-MM"),
    "IsWorkday", WEEKDAY([Date], 2) < 6
)

Then right-click, choose Mark as Date Table, and sort the MonthName column by MonthNo (Sort by Column).

Without a continuous, marked date table, time intelligence functions such as SAMEPERIODLASTYEAR return wrong or empty results. If some months have no data, those months don't appear in the calculation at all.

How do you implement and test row-level security (RLS)?

▾Intermediate
-- In Modeling → Manage Roles, on the Customer table:
[Region] = LOOKUPVALUE(
    UserRegion[Region],
    UserRegion[Email], USERPRINCIPALNAME()
)

-- Or simpler, for a fixed role:
[Region] = "Tehran"

In Desktop you test with View as Role, and in the Service you assign users to roles.

  • Static RLS: one role per group; simple, but unmaintainable as the number of groups grows.
  • Dynamic RLS: a single role that reads access from a mapping table using the current user's email.
RLS only filters data; it doesn't hide the model's structure. To hide sensitive columns you must remove them from the model or build a separate model. A user with Build permission can explore the model.

How does incremental refresh work?

▾Intermediate
  1. In Power Query, create two date/time parameters named exactly RangeStart and RangeEnd.
  2. Filter the fact table's date column with these two parameters.
  3. Enable incremental refresh on the table: for example “archive 5 years, refresh 10 days”.
  4. After publishing, the service partitions the table and reads only the recent partitions.

Its success depends on query folding: the filter must be pushed down to the source, otherwise all the data is read and then filtered, and you gain nothing. Be sure to mention this condition.

A report is slow. In what order do you troubleshoot?

▾Intermediate
  1. Turn on Performance Analyzer and see which visual is slow, and how much of it is DAX query versus visual display.
  2. Run the DAX query in DAX Studio and look at the ratio of Formula Engine to Storage Engine time; a high FE share means iterative, row-by-row DAX.
  3. Simplify the model: remove unused columns, reduce cardinality (for example by splitting date and time), remove heavy calculated columns.
  4. Reduce the number of visuals per page; each visual is a separate query.
  5. Limit page- and report-level filters and use sensible defaults.
  6. For large models, define aggregation tables.

Mentioning the split between the Formula Engine and the Storage Engine is a sign of real optimization experience. Most candidates only get as far as Performance Analyzer.

Where do you use drillthrough, bookmarks and custom tooltips?

▾Intermediate
  • Drillthrough: the user right-clicks a city and goes to that city's details page; the filter context is carried over.
  • Bookmark: saves a state (filters, visible visuals) for building a narrative or a button that switches charts.
  • Tooltip page: a small page that shows details when the mouse hovers over a point on a chart.
  • Drill down: moving through the year → quarter → month hierarchy within one visual.

Combining bookmarks with the Selection Pane to build a “show as table / chart” button is common and looks good in a portfolio.

What is a dataflow, and when is it used instead of Power Query in the file?

▾Intermediate

A dataflow is Power Query that runs in the service and stores its output, so several reports can use one shared transformation logic.

  • The cleaning logic is written once and used everywhere.
  • Load on the source drops; it is read once instead of five times.
  • A single definition of reference tables (customer, product, calendar) across the organization.

If they ask how it differs from a semantic model: a dataflow is the data preparation layer, a semantic model is the modeling and calculation layer.

How do workspaces, apps and licenses work?

▾Intermediate
  • A workspace is the builders' working area, with Admin, Member, Contributor and Viewer roles.
  • An app is a published, organized package for end consumers; end users usually see the app, not the workspace.
  • To view shared content, both sides need a Pro license; unless the content is in Premium/Fabric capacity, in which case consumers can have a free license.

The standard deployment flow: Dev → Test → Prod with a deployment pipeline. Saying this shows you think about the report's life cycle, not just building it.

What is a field parameter and what problem does it solve?

▾Intermediate

It lets users switch a visual's metric or dimension themselves, without you building several separate visuals.

-- Modeling → New parameter → Fields
Metrics = {
    ("Revenue",    NAMEOF('_Measures'[Total Sales]),  0),
    ("Orders",     NAMEOF('_Measures'[Total Orders]), 1),
    ("Customers",  NAMEOF('_Measures'[Customers]),    2),
    ("Avg basket", NAMEOF('_Measures'[AOV]),          3)
}

Then put the generated table in a slicer and the parameter itself on the visual's axis.

This way one page replaces five repetitive pages, and maintaining the report becomes much simpler. You can build the same thing for switching dimensions (city, product, channel).

How do you prepare a report for accessibility and mobile?

▾Intermediate
  • Mobile layout in Desktop: a separate vertical layout with the key visuals.
  • Set the tab order in the Selection Pane so a screen reader moves through it logically.
  • Write alt text for every visual.
  • Sufficient contrast, and don't convey meaning through color alone; use markers and labels too.
  • Don't use a font size below 10, and avoid rotated labels.

A significant share of users open reports on their phones. If you don't define a mobile layout, the whole wide page is shrunk down and becomes practically unusable.

We want to see the distribution of order amounts. Which chart?

▾Intermediate

A histogram for the overall shape of the distribution and a box plot for comparing distributions between groups.

-- Creating bins in Power BI: on a numeric column → New group → Bin
-- Or a calculated column for custom bins
Amount Band =
SWITCH ( TRUE (),
    Sales[Amount] < 100000,  "Under 100K",
    Sales[Amount] < 300000,  "100K to 300K",
    Sales[Amount] < 1000000, "300K to 1M",
    "Over 1M" )

An average alone hides the distribution. If a manager only sees the average basket size, they may not know that eighty percent of orders are very small and a few large orders pulled the average up. A histogram shows this at a glance.

We want to show the conversion funnel and the drop at each step. Which chart?

▾Intermediate

A funnel chart for display, together with a small table giving each step's conversion rate.

Step Count Conversion from previous View 52,000 — Add to cart 11,400 21.9% Checkout 5,900 51.8% Purchase 3,100 52.5%

The funnel chart itself is pretty but doesn't state the important numbers. The conversion rate of “each step relative to the previous one” is what drives decisions — and the default funnel chart doesn't show it. Always add it as labels or a table next to it.

We want to show cohort retention. Which chart?

▾Intermediate

A matrix with conditional formatting (a retention triangle): cohorts in rows, month number in columns, the retention percentage in each cell on a color scale.

Cohort M0 M1 M2 M3 2024-04 100% 32.0% 24.1% 19.4% 2024-05 100% 35.1% 27.6% 22.8% 2024-06 100% 38.7% 30.2% —

Three things can be read from this matrix: the first-step drop, the curve flattening in later months, and a vertical comparison of cohorts.

A good complement is a line chart with one line per cohort and “months since start” on the horizontal axis — this version shows more clearly whether the curve flattens.

We want to show sales on a map of Iran. What matters?

▾Intermediate
  • A filled map for comparing provinces, a bubble map for absolute values at points.
  • In Power BI, set the column's Data Category correctly (Province, City), otherwise points land in the wrong place.
  • Place names in local languages are sometimes not recognized correctly; add latitude and longitude columns or a standard code.
  • For a filled map, normalize the value: sales per capita, not total sales.
A filled map without normalization always shows the most populous provinces as darkest and adds no information. If the question is “which province is the better market”, you need to show per-capita values, not totals.

We want to see actual performance against budget. Which chart?

▾Intermediate

Three options, in order of preference:

  1. An actual bar with a target line on the same axis (a combo chart with one column and one line).
  2. A bullet chart, which shows the value, the target and the performance ranges in little space.
  3. A variance chart: show only the difference, positive in green and negative in red.
Category Actual Budget Variance Audio 480M 420M +60M ▲ Accessories 310M 380M −70M ▼

Two bars side by side (actual and budget) is the most common choice but the worst: the eye has to calculate the difference. A variance chart has already done that work and delivers the message instantly.

When is a table or matrix better than a chart?

▾Intermediate
  • When the user needs the exact number, not a pattern — for example a financial report that has to be reconciled with the accounting system.
  • When several unrelated metrics are needed for each row.
  • When the output is going to be exported to Excel.
  • When there are so many categories that any chart gets cluttered.

For tables: conditional formatting with data bars, a meaningful default sort, right-aligned numeric columns, and a frozen header.

A table plus conditional formatting is often the best combination: it has numeric precision and shows the pattern with color. Underestimating it is a common mistake of beginner designers.

Advanced level 11

What is a calculation group and what problem does it solve?

▾Advanced

When you have 20 metrics and need YTD, prior-year and growth versions of each, you effectively need 80 measures. A calculation group defines this logic once and applies it to any metric.

-- In Tabular Editor, a calculation group named "Time Intelligence"
-- Item: Current
SELECTEDMEASURE()

-- Item: YTD
CALCULATE(SELECTEDMEASURE(), DATESYTD('Date'[Date]))

-- Item: PY
CALCULATE(SELECTEDMEASURE(), SAMEPERIODLASTYEAR('Date'[Date]))

-- Item: YoY %
VAR cur = SELECTEDMEASURE()
VAR py  = CALCULATE(SELECTEDMEASURE(), SAMEPERIODLASTYEAR('Date'[Date]))
RETURN DIVIDE(cur - py, py)

Building it requires Tabular Editor (it isn't directly supported in Desktop). Mentioning exactly this detail proves hands-on experience.

How do aggregation tables make a large model fast?

▾Advanced

A small pre-aggregated table (for example daily sales by city) is kept in memory while the detail table stays in DirectQuery. The engine automatically detects which queries can be answered from the aggregation table.

  • A user who only looks at monthly totals hits the small table and gets an instant answer.
  • When they drill down to the transaction level, the query goes to the original source.

The concept is similar to a materialized view in a database. If you have SQL experience, draw exactly this parallel to show you understand the concept, not just the buttons.

The model has grown too large. What do you do?

▾Advanced

Size in VertiPaq is mainly a function of column cardinality, not the number of rows.

  1. Remove unused columns, especially text ids and GUIDs.
  2. Split a datetime column into date and time — cardinality drops sharply.
  3. Round high-precision decimals or change them to Fixed Decimal.
  4. Move calculated columns to Power Query or the source.
  5. Limit the history you keep and set up incremental refresh.
  6. Use VertiPaq Analyzer in DAX Studio to see which column takes the most space.

An effective line: “Measure first with VertiPaq Analyzer, then remove. Usually two or three high-cardinality columns take up half the model.”

How are version control and teamwork done on a Power BI file?

▾Advanced
  • Save as PBIP (a project), which turns the model and report into TMDL and JSON text files that can be diffed in Git.
  • Separate the model from the reports: one central semantic model and several reports connected to it (Live Connection).
  • A deployment pipeline to move from Dev to Test and Prod.
  • Tabular Editor for bulk-editing measures and running the Best Practice Analyzer.
A .pbix file is a binary; storing it in Git only produces bulky versions that can't be compared. Pointing out this limitation and the PBIP solution sets your answer apart.

Scheduled refresh is failing. How do you find the root cause?

▾Advanced
  1. Open the refresh history in the model settings and read the exact error text.
  2. If it's an authentication error: the source credentials have expired or the password has changed.
  3. If the source is on-premises: check the gateway's status, its Windows service and the data source mapping.
  4. If it's a timeout: a heavy query or no query folding; move the logic to the source.
  5. If it's a memory error: the model has exceeded capacity; incremental refresh or fewer columns.
  6. If a column can't be found: the source changed its schema; a rename or type-change step in Power Query has broken.

The last case is the most frequent cause in practice. Prevention: in Power Query, use more robust patterns than selecting columns with a fixed “Remove Other Columns”, and review the automatic “Changed Type” step.

What design qualities does a good Power BI report have?

▾Advanced
  • Each page answers one business question; the first page is a summary, the following pages give details.
  • Five to seven visuals per page; more than that is both confusing and slow.
  • A visual's title states the finding, not just the field name.
  • A limited, meaningful color palette; color only to convey meaning, not for decoration.
  • Always pair a number with a comparison: against the previous period or a target.
  • Units and metric definitions are documented on a guide page.
  • Accessibility: tab order, alt text, sufficient contrast.

If you say “before building, I sit down with the stakeholder and pin down what decision is going to be made”, you score more than with any technical point.

How do you sum up the difference between Power BI and tools like Metabase or Tableau?

▾Advanced
Power BIMetabase
Model layerStrong, with DAX and a semantic modelLight, mostly query-based
Learning curveSteepGentle
Self-service for non-technical usersGood, but needs a ready modelVery fast
CostPer-user licenseFree open-source edition
Best forComplex models and heavy calculationsQuick reports on an existing database

Give a balanced answer: you choose the tool based on data maturity and the audience, not personal preference. If the company has both, explain the logic for dividing the work.

Find common Power BI model mistakes with Best Practice Analyzer.

▾Advanced

In Tabular Editor, the Best Practice Analyzer checks the model against a set of rules.

  • Unused high-cardinality columns that only take up space.
  • Combined date-time columns that should be split.
  • Unnecessary bidirectional relationships.
  • Measures without a number format or display folder.
  • Using the division operator instead of DIVIDE.
  • Unhidden columns in the fact table that confuse users.

Running this tool on every model before publishing takes ten minutes and usually finds several real problems. Naming it in an interview shows you have a disciplined workflow, not just report-building skills.

We want to show how users move between pages. Which chart?

▾Advanced

A Sankey diagram for flows between steps, or a decomposition tree for interactive exploration.

  • A Sankey shows the thickness of each flow and is great for two to four steps.
  • Beyond four steps the chart turns into a tangled mess; then a table of the most frequent paths is better.
  • A decomposition tree lets the user choose the next dimension themselves and drill down.

Both are custom or specialized visuals, and before using them you need to be sure they are allowed and supported in the organization's environment. If not, a table of the ten most frequent paths with their percentages gives ninety percent of the value with zero risk.

Explain the rules of color in a report.

▾Advanced
  • Color should carry meaning, not decoration. If all the bars are one category, they should all be one color.
  • Accent color: gray for everything, one color for what matters.
  • A sequential scale for continuous values, a diverging scale for deviation around a reference point.
  • Consistency: if “Tehran” is blue in one chart, it stays blue everywhere.
  • Color blindness: about eight percent of men can't tell red from green; use shapes or labels in addition to color.
  • Sufficient contrast with the background, especially in a dark theme.

Practical rule: before adding any color, ask “what does this color encode?” If you have no answer, use gray. Professional reports usually use no more than two or three colors.

How do you spot and fix a misleading chart?

▾Advanced
ProblemEffectFix
Truncated vertical axisA small difference looks hugeStart at zero or mark it explicitly
Dual axisCreates a false correlationTwo separate charts, or normalize to an index
Area scalingExaggerates the difference several timesLength instead of area
Cherry-picked time rangeManufactures the desired trendA complete, meaningful range
3D effectsDistort the reading of valuesNever use 3D charts
Comparing unequal periodsMeaningless comparisonPeriods of equal length

The dual axis is the most debated case. Sometimes it's needed, but since changing the scale can create or erase any relationship, it isn't trustworthy. The safe alternative: convert both series to an index of 100 at the start of the period and put them on one axis.

DAX

At the heart of DAX is one concept: evaluation context. Once you truly understand the difference between row context and filter context, and the idea of context transition, most of the language's apparent complexity disappears. The questions in this section start right there and go on to time intelligence, calculation groups and optimization.

DAX review sheet

The patterns used most in real reports.DAX
Sales = SUM(Fact[Amount])
Simple aggregation over one column
Rev = SUMX(Fact, Fact[Qty] * Fact[Price])
Iterator: row-by-row calculation, then sum
DIVIDE(a, b, 0)
Safe division; replaces the / operator
CALCULATE(expr, filter1, filter2)
Changes the filter context — the most important DAX function
CALCULATE(expr, ALL(Table))
Removes all filters; for share of total
CALCULATE(expr, ALLEXCEPT(T, T[Col]))
Removes all filters except the listed columns
CALCULATE(expr, KEEPFILTERS(cond))
Adds a condition without overwriting the existing filter
VAR x = ... RETURN ...
Variable; computed once, readable and fast
SAMEPERIODLASTYEAR('Date'[Date])
The same period last year
DATESYTD / TOTALYTD
Cumulative from the start of the year
DATEADD('Date'[Date], -1, MONTH)
Flexible period shifting
RANKX(ALL(T[Col]), [Measure], , DESC)
Dynamic ranking
SELECTEDVALUE(T[Col], "All")
The selected value or a fallback value
RELATED(Dim[Col])
A value from the “one” side of a relationship
USERELATIONSHIP(a, b)
Temporarily activates an inactive relationship
ISINSCOPE(T[Col])
Detects the hierarchy level for subtotals

Basic level 15

What is DAX and how does it differ from SQL?

▾Basic

DAX is the formula language of tabular models (Power BI, Power Pivot, Analysis Services). Unlike SQL, which produces one fixed result, a DAX formula is re-evaluated in every cell of a visual with a different filter context.

-- SQL: the result is fixed
SELECT city, SUM(amount) FROM sales GROUP BY city;
-- DAX: one definition, recalculated in every context
Total Sales = SUM(Sales[Amount])

The same single measure gives three different numbers in a table of cities, in a monthly chart and in a summary card — because its filter context differs.

What is the difference between SUM and SUMX?

▾Basic
-- SUM: works only on an existing column
Total Amount = SUM(Sales[Amount])

-- SUMX: computes an expression row by row, then sums
Revenue = SUMX(Sales, Sales[Qty] * Sales[UnitPrice])

-- Common mistake: these two are not the same
Wrong = SUM(Sales[Qty]) * SUM(Sales[UnitPrice])
Revenue = 1,240,000 correct Wrong = 8,600,000 sum of quantity × sum of price

Wherever the calculation must be done at row level and then aggregated, you need an iterator. The X functions (SUMX, AVERAGEX, MAXX, COUNTX) do exactly this.

Why do we use DIVIDE instead of the / operator?

▾Basic
Margin % = DIVIDE([Profit], [Revenue], 0)

DIVIDE handles division by zero and by BLANK without an error, and its third argument is a fallback value. The / operator returns an error or infinity in these cases, which breaks the whole visual.

If you don't pass the third argument, the result is BLANK — which in a chart means “don't draw a point”. Sometimes that is exactly the behavior you want, so choose it deliberately.

Write a multi-case condition with SWITCH.

▾Basic
-- Simple form: value matching
Region Group =
SWITCH(
    Store[Region],
    "Tehran", "Central",
    "Karaj",  "Central",
    "Mashhad", "East",
    "Other"
)

-- Conditional form: SWITCH(TRUE())
Performance =
SWITCH(
    TRUE(),
    [Sales] >= [Target] * 1.1, "Above target",
    [Sales] >= [Target],       "On target",
    [Sales] >= [Target] * 0.8, "Near target",
    "Below target"
)

The SWITCH(TRUE()) pattern is a more readable replacement for nested IFs. The order of the conditions matters: the first true condition wins.

How does BLANK behave in DAX?

▾Basic
BLANK() + 5      = 5
BLANK() * 5      = BLANK()
BLANK() = 0      → TRUE   (comparison, not true equality)
ISBLANK([M])     exact detection

-- Show zero instead of blank, when needed
Sales Zero = IF(ISBLANK([Total Sales]), 0, [Total Sales])
If you write a measure so it never returns BLANK, visuals show every possible combination and tables become very heavy. Returning BLANK is the right default behavior.

Which measures do you build first in every model?

▾Basic
Total Sales   = SUM(Sales[Amount])
Total Orders  = COUNTROWS(Sales)
Total Qty     = SUM(Sales[Qty])
Customers     = DISTINCTCOUNT(Sales[CustomerID])
AOV           = DIVIDE([Total Sales], [Total Orders])
Revenue per Customer = DIVIDE([Total Sales], [Customers])
City | Total Sales | Total Orders | AOV --------+-------------+--------------+-------- Tehran | 183,000,000 | 1,120 | 163,392 Mashhad | 58,000,000 | 402 | 144,278

A good habit: collect your measures in an empty table called _Measures. That way they all appear together in the field list and don't depend on the fact table.

How do you create a measure, and where do you write it?

▾Basic
  1. On the Modeling tab, click New Measure.
  2. Write the name and formula in the formula bar: Total Sales = SUM(Sales[Amount]).
  3. Set the number format and decimal places under Measure tools.
  4. Drag the measure onto a visual.
Total Sales =
SUM ( Sales[Amount] )   // spacing is optional; it improves readability

In DAX a column name is always written with its table name (Sales[Amount]), but a measure name is written without a table, in brackets ([Total Sales]). Following this convention means you can tell at a glance whether you're dealing with a column or a measure when reading code.

Review DAX operators and syntax rules.

▾Basic
= <> > < >= <=      comparison
+ - * / ^             arithmetic
&&  ||  NOT           logical (equivalent to AND, OR)
&                     text concatenation
IN { "a", "b" }       set membership

Example =
IF (
    [Total Sales] > 1000000 && SELECTEDVALUE ( Customer[City] ) IN { "Tehran", "Karaj" },
    "Target",
    "Normal"
)

Unlike SQL, in DAX strings are written in double quotes, and single quotes are for table names that contain spaces, like 'Date'[Date]. Swapping these two is a common mistake for people coming from SQL.

Why is my measure's number the same in every row?

▾Basic

There are three common causes:

  1. There is no relationship between the dimension table and the fact table, or it is inactive.
  2. The column you used in the visual comes from the fact table itself, not from the connected dimension table.
  3. You used ALL in the measure and removed the filter unintentionally.
-- The same number everywhere, because the city filter is removed
Wrong = CALCULATE([Total Sales], ALL(Customer))

-- Correct
Right = [Total Sales]

A quick diagnosis: take the city column out of the fact table and use the one from the Customer table instead. If the number becomes correct, the problem was the relationship.

Write a simple calculated column.

▾Basic
Line Total  = Sales[Qty] * Sales[UnitPrice]
Order Year  = YEAR ( Sales[OrderDate] )
Full Name   = Customer[FirstName] & " " & Customer[LastName]
Is Weekend  = WEEKDAY ( Sales[OrderDate], 2 ) >= 6
City Group  = RELATED ( Region[GroupName] )
Qty | UnitPrice | Line Total | Order Year | Is Weekend ----+-----------+------------+------------+----------- 2 | 450,000 | 900,000 | 2024 | FALSE 3 | 180,000 | 540,000 | 2024 | TRUE

In a calculated column you have direct access to the value of the current row, because you are in row context. If you write the same formula in a measure you get an error, because a measure has no row context and you need SUMX.

What is the difference between COUNTROWS, COUNT, COUNTA and DISTINCTCOUNT?

▾Basic
Rows          = COUNTROWS ( Sales )                  -- number of table rows
Numbers       = COUNT ( Sales[Amount] )              -- non-empty numeric cells
NonBlank      = COUNTA ( Sales[Note] )               -- any non-empty value
Customers     = DISTINCTCOUNT ( Sales[CustomerID] )  -- distinct values
CustomersNB   = DISTINCTCOUNTNOBLANK ( Sales[CustomerID] )
Blanks        = COUNTBLANK ( Sales[Note] )
Rows: 41,200 Numbers: 41,187 Customers: 18,940

To count rows, always prefer COUNTROWS over COUNT on a column: your intent is clearer, and you don't get a wrong number if that column has blank values.

Build a percentage measure that works correctly with filters.

▾Basic
-- Percentage of the total, ignoring the city filter
Pct of Total City =
DIVIDE (
    [Total Sales],
    CALCULATE ( [Total Sales], REMOVEFILTERS ( Customer[City] ) )
)

-- Percentage of the total, respecting the user's selected filters
Pct of Visual Total =
DIVIDE ( [Total Sales], CALCULATE ( [Total Sales], ALLSELECTED () ) )
City | Total Sales | Pct of Total | Pct of Visual --------+-------------+--------------+-------------- Tehran | 183,000,000 | 64.9% | 75.9% Mashhad | 58,000,000 | 20.6% | 24.1%

The difference between the last two columns matters: REMOVEFILTERS opens up every dimension, but ALLSELECTED keeps the outer filters (slicers). For “percentage of what the user sees”, the second is right.

How do you handle blanks and zeros in a measure?

▾Basic
-- Leave it blank so rows without data aren't shown in the visual
Sales = SUM ( Sales[Amount] )

-- Show zero (makes every combination appear in the matrix)
Sales Zero = SUM ( Sales[Amount] ) + 0

-- Explicit control
Sales Safe =
VAR s = SUM ( Sales[Amount] )
RETURN IF ( ISBLANK ( s ), 0, s )
Adding + 0 is a common trick but has side effects: the matrix suddenly shows every possible combination of dimensions and can grow from a few hundred rows to a few hundred thousand. Only use it when you really want empty rows to be visible.

Build a date table in DAX and mark it.

▾Basic
Date =
VAR MinD = MIN ( Sales[OrderDate] )
VAR MaxD = MAX ( Sales[OrderDate] )
RETURN
ADDCOLUMNS (
    CALENDAR ( DATE ( YEAR(MinD), 1, 1 ), DATE ( YEAR(MaxD), 12, 31 ) ),
    "Year",        YEAR ( [Date] ),
    "MonthNo",     MONTH ( [Date] ),
    "MonthName",   FORMAT ( [Date], "MMMM" ),
    "Quarter",     "Q" & QUARTER ( [Date] ),
    "YearMonth",   FORMAT ( [Date], "YYYY-MM" ),
    "DayName",     FORMAT ( [Date], "dddd" ),
    "IsWeekend",   WEEKDAY ( [Date], 2 ) >= 6
)

After building it, be sure to choose Mark as date table on the Table tools tab. Without it, time intelligence functions such as SAMEPERIODLASTYEAR may give wrong results. And sort the MonthName column by MonthNo.

What is a variable (VAR) good for?

▾Basic
-- Without a variable: [Total Sales] is computed three times
Growth Bad =
DIVIDE ( [Total Sales] - CALCULATE([Total Sales], PREVIOUSMONTH('Date'[Date])),
         CALCULATE([Total Sales], PREVIOUSMONTH('Date'[Date])) )

-- With a variable: more readable and faster
Growth =
VAR Curr = [Total Sales]
VAR Prev = CALCULATE ( [Total Sales], PREVIOUSMONTH ( 'Date'[Date] ) )
RETURN
    IF ( NOT ISBLANK ( Prev ), DIVIDE ( Curr - Prev, Prev ) )

A subtle point: a variable is computed in the filter context where it is defined, and its value stays fixed afterwards, even if it is used inside CALCULATE. This property is sometimes the solution to tricky filter-context problems and sometimes a source of confusion.

Intermediate level 15

Define row context and filter context precisely.

▾Intermediate
  • Filter context: the set of active filters — from the visual's rows and columns, slicers, page filters and CALCULATE arguments. Measures are evaluated in this context.
  • Row context: the “current row” while computing a calculated column or inside an iterator. On its own it applies no filter to the model.
-- Row context: has access to the current row
Line Total = Sales[Qty] * Sales[Price]

-- Filter context: affected by the visual and slicers
Total = SUM(Sales[Amount])

A common fallacy: assuming a calculated column also sees the visual's filters. A column is computed and stored when the data is loaded, so it knows nothing about the user's selections.

What does CALCULATE do, and why is it the most important DAX function?

▾Intermediate

It is the only function that can change the filter context: add a filter, remove one or replace one.

-- New filter; an existing filter on the same column is overwritten
Sales Tehran = CALCULATE([Total Sales], Customer[City] = "Tehran")

-- Remove a filter
Sales All Cities = CALCULATE([Total Sales], ALL(Customer[City]))

-- Add without overwriting
Sales Big Tehran =
CALCULATE([Total Sales], KEEPFILTERS(Customer[City] = "Tehran"))
City | Total Sales | Sales Tehran | Sales Big Tehran --------+-------------+--------------+----------------- Tehran | 183,000,000 | 183,000,000 | 183,000,000 Mashhad | 58,000,000 | 183,000,000 | BLANK

Explain the difference between the third and fourth columns: without KEEPFILTERS the city filter is overwritten, so in the “Mashhad” row you also see Tehran's number; with KEEPFILTERS the intersection of the two filters is taken and the result is blank.

What does context transition mean?

▾Intermediate

When CALCULATE runs in the presence of a row context, that row is turned into a filter. Every measure call is also implicitly wrapped in CALCULATE, so the same thing happens.

-- Without transition: sees the whole table, the same number for everyone
Wrong = SUMX(Customer, [Total Sales])   -- transition actually does happen here

-- Explicit and clear
Customer Rank Base =
SUMX(
    VALUES(Customer[CustomerID]),
    CALCULATE([Total Sales])            -- the current row becomes a filter
)

This concept is exactly what separates senior-level DAX questions from intermediate ones. If you can explain it with a simple example, your command of the language is obvious.

Explain the difference between ALL, ALLEXCEPT and ALLSELECTED with an example.

▾Intermediate
-- Share of the absolute total (ignoring slicers)
% of Grand Total =
DIVIDE([Total Sales], CALCULATE([Total Sales], ALL(Sales)))

-- Share of the user's selected total
% of Selected =
DIVIDE([Total Sales], CALCULATE([Total Sales], ALLSELECTED()))

-- Share of that city's total
% of City =
DIVIDE([Total Sales], CALCULATE([Total Sales], ALLEXCEPT(Customer, Customer[City])))
Product | Sales | % Grand | % Selected | % City -----------+--------+---------+------------+------- Headphones | 48,000 | 16.4% | 32.1% | 26.5% Keyboard | 31,000 | 10.6% | 20.7% | 17.1%

ALLSELECTED removes the filters inside the visual but keeps the outer filters (slicers and page). For “percentage of what the user selected”, this is always the right one.

Why is using VAR recommended?

▾Intermediate
YoY % =
VAR CurrentSales  = [Total Sales]
VAR PriorSales    = CALCULATE([Total Sales], SAMEPERIODLASTYEAR('Date'[Date]))
VAR Growth        = CurrentSales - PriorSales
RETURN
    IF(
        NOT ISBLANK(PriorSales) && PriorSales <> 0,
        DIVIDE(Growth, PriorSales)
    )
  • Each variable is computed once, even if it is used several times.
  • A variable is evaluated in the context where it is defined and doesn't change afterwards — this behavior makes debugging simpler.
  • The code becomes readable and testable: you can temporarily point RETURN at one variable and see its value.

Mention that same debugging technique (pointing RETURN at an intermediate variable) in the interview; it shows you've really written complex DAX.

Write a comparison with last year and the growth.

▾Intermediate
Sales PY  = CALCULATE([Total Sales], SAMEPERIODLASTYEAR('Date'[Date]))
Sales PM  = CALCULATE([Total Sales], PREVIOUSMONTH('Date'[Date]))
Sales YTD = TOTALYTD([Total Sales], 'Date'[Date])

-- A more flexible version with DATEADD
Sales LY Flexible = CALCULATE([Total Sales], DATEADD('Date'[Date], -1, YEAR))

YoY %  = DIVIDE([Total Sales] - [Sales PY], [Sales PY])
Month | Total Sales | Sales PY | YoY % ------+--------------+------------+------- Jan | 120,000,000 | 98,000,000 | +22.4% Feb | 150,000,000 |155,000,000 | -3.2%

For a non-calendar fiscal year, pass the year-end argument: TOTALYTD([Total Sales], 'Date'[Date], "03-20") (the Iranian fiscal year, for example). In such environments this point is practical and sets you apart.

Write a running total.

▾Intermediate
Running Total =
VAR LastDate = MAX('Date'[Date])
RETURN
CALCULATE(
    [Total Sales],
    'Date'[Date] <= LastDate,
    ALL('Date')
)
Month | Sales | Running Total --------+-------------+-------------- Jan | 120,000,000 | 120,000,000 Feb | 150,000,000 | 270,000,000 Mar | 135,000,000 | 405,000,000

Two points: MAX takes the last date of the current context, and ALL('Date') removes the current month's filter so we can look backwards. If you remove ALL, the result equals the month itself.

Write a dynamic ranking with RANKX.

▾Intermediate
Product Rank =
IF(
    HASONEVALUE(Product[ProductName]),
    RANKX(
        ALLSELECTED(Product[ProductName]),
        [Total Sales],
        ,
        DESC,
        DENSE
    )
)
Product | Total Sales | Product Rank -----------+-------------+------------- Headphones | 480,000,000 | 1 Keyboard | 310,000,000 | 2 Mouse | 220,000,000 | 3 Total |1,010,000,000| BLANK

The HASONEVALUE condition prevents a meaningless rank from showing in the grand-total row. Using ALLSELECTED keeps the ranking in step with the user's selection.

Difference between RELATED and RELATEDTABLE?

▾Intermediate
-- In the Sales table (many side): brings one value from the dimension
Customer City = RELATED(Customer[City])

-- In the Customer table (one side): brings the table of related rows
Order Count = COUNTROWS(RELATEDTABLE(Sales))

A simple rule: from the “many” side to the “one” side with RELATED, and from the “one” side to the “many” side with RELATEDTABLE. Both need an active relationship.

Where are SELECTEDVALUE and HASONEVALUE needed?

▾Intermediate
-- Dynamic title for a visual
Dynamic Title =
"Sales in " & SELECTEDVALUE(Customer[City], "all cities")
      & ", " & SELECTEDVALUE('Date'[Year], "all years")

-- Protecting the calculation in the grand-total row
Unit Price =
IF(HASONEVALUE(Product[ProductID]), DIVIDE([Revenue], [Quantity]))
Sales in Tehran, 1403

Wherever a calculation only makes sense for a single item (unit price, rank, rate), guard it with HASONEVALUE so the total rows don't produce misleading numbers.

Write a semi-additive measure for inventory.

▾Intermediate
-- Stock on the last day with data in the selected range
Closing Stock =
CALCULATE (
    SUM ( Inventory[OnHandQty] ),
    LASTNONBLANK ( 'Date'[Date], CALCULATE ( COUNTROWS ( Inventory ) ) )
)

Opening Stock =
CALCULATE (
    SUM ( Inventory[OnHandQty] ),
    FIRSTNONBLANK ( 'Date'[Date], CALCULATE ( COUNTROWS ( Inventory ) ) )
)
Month | Closing Stock ----------+-------------- Jan | 3,140 Feb | 2,980 Q1 | 2,980 ← not the sum, but the last value

Summing inventory over time is meaningless; you have to take the last value. LASTNONBLANK is preferable to LASTDATE, because if the last day has no data it steps back instead of returning a blank row.

Build a “between two chosen values” measure with a numeric parameter.

▾Intermediate
-- What-if parameter: Discount % from 0 to 30 in steps of 1
Simulated Revenue =
VAR d = SELECTEDVALUE ( 'Discount %'[Discount %], 0 )
VAR uplift = 1 + d * 0.03          -- assumption: each 1% discount gives 3% volume growth
RETURN
SUMX ( Sales, Sales[Qty] * uplift * Sales[UnitPrice] * ( 1 - d ) )

Margin Impact = [Simulated Revenue] - [Total Sales]
Discount % | Simulated Revenue | Margin Impact -----------+-------------------+-------------- 0% | 291,000,000 | 0 10% | 297,900,000 | +6,900,000 20% | 288,100,000 | -2,900,000

A what-if parameter creates a standalone numeric table that isn't connected to the model, so its filter doesn't affect the data and it is read only with SELECTEDVALUE. A good tool for interactive scenario building in management meetings.

Write a previous-period comparison for a non-standard fiscal calendar.

▾Intermediate
-- When the calendar is Solar Hijri or fiscal and the built-in Gregorian functions don't work
Sales PY Custom =
VAR CurrIdx = SELECTEDVALUE ( 'Date'[MonthIndex] )   -- continuous month counter
RETURN
CALCULATE (
    [Total Sales],
    REMOVEFILTERS ( 'Date' ),
    'Date'[MonthIndex] = CurrIdx - 12
)

YoY % = DIVIDE ( [Total Sales] - [Sales PY Custom], [Sales PY Custom] )

The “continuous counter” trick is very practical: an integer column that numbers the months consecutively from the start of the data. With it, a time shift becomes a simple subtraction and you escape the calendar's complexity — especially for the Solar Hijri calendar, which DAX's built-in functions don't support.

Aggregate table rows with SUMMARIZECOLUMNS.

▾Intermediate
EVALUATE
SUMMARIZECOLUMNS (
    'Date'[Year],
    Customer[City],
    FILTER ( ALL ( 'Date'[Year] ), 'Date'[Year] >= 2023 ),
    "Sales",     [Total Sales],
    "Orders",    [Total Orders],
    "AOV",       DIVIDE ( [Total Sales], [Total Orders] )
)
ORDER BY [Sales] DESC
Year | City | Sales | Orders | AOV -----+--------+-------------+--------+-------- 2024 | Tehran | 183,000,000 | 1,120 | 163,392 2023 | Tehran | 141,000,000 | 980 | 143,878

This function is what Power BI itself generates behind every visual. Unlike SUMMARIZE, which is problematic for building calculated columns, this one is optimized and automatically drops combinations without data.

Write a “cumulative share” measure and Pareto analysis in DAX.

▾Intermediate
Cumulative Sales =
VAR CurrSales = [Total Sales]
VAR Visible = ALLSELECTED ( Product[ProductName] )
VAR WithSales =
    ADDCOLUMNS ( Visible, "@s", CALCULATE ( [Total Sales] ) )
RETURN
    SUMX ( FILTER ( WithSales, [@s] >= CurrSales ), [@s] )

Cumulative % =
DIVIDE ( [Cumulative Sales], CALCULATE ( [Total Sales], ALLSELECTED () ) )
Product | Sales | Cumulative % -----------+-------------+------------- Headphones | 480,000,000 | 41.2% Keyboard | 310,000,000 | 67.8% Monitor | 220,000,000 | 86.7%

This pattern is “accumulation by rank”, not by time: for each row, the total of all rows whose value is greater than or equal is computed. With it you can easily show how many products make up eighty percent of revenue.

Advanced level 17

What is the difference between FILTER(Table, ...) and a simple condition inside CALCULATE?

▾Advanced
-- Efficient: a column filter, pushed down to the Storage Engine
Good = CALCULATE([Total Sales], Sales[Amount] > 1000000)

-- The internal equivalent of the above
Same = CALCULATE([Total Sales], FILTER(ALL(Sales[Amount]), Sales[Amount] > 1000000))

-- Expensive: walks the entire fact table row by row
Bad = CALCULATE([Total Sales], FILTER(Sales, Sales[Amount] > 1000000))

Rule: filter on a column, not on a table, whenever possible. A column filter runs in the Storage Engine on compressed data; a table filter forces the Formula Engine into a row-by-row scan. This is the most frequent cause of slow DAX.

How and why do you use an inactive relationship?

▾Advanced

When the fact table has several date columns (order date, ship date, payment date), only one relationship with the date table can be active; the rest are defined as inactive.

Sales by Ship Date =
CALCULATE(
    [Total Sales],
    USERELATIONSHIP(Sales[ShipDate], 'Date'[Date])
)
Month | Sales by Order | Sales by Ship --------+----------------+-------------- Jan | 120,000,000 | 104,000,000 Feb | 150,000,000 | 161,000,000

The alternative is to build a second date table (a role-playing dimension). Its advantage is that the user can slice on both dates at once; the cost is a more complex model. Explaining this choice is a sign of mature modeling.

How do you write a semi-additive measure such as inventory?

▾Advanced

Inventory isn't additive over time: stock for three months is not the sum of three numbers, it is the value on the last day.

Closing Stock =
CALCULATE(
    SUM(Inventory[Qty]),
    LASTDATE('Date'[Date])
)

-- If not every day has a record:
Closing Stock Safe =
CALCULATE(
    SUM(Inventory[Qty]),
    LASTNONBLANK('Date'[Date], CALCULATE(SUM(Inventory[Qty])))
)
Month | Sum of Qty | Closing Stock --------+------------+-------------- Jan | 91,200 | 3,140 Feb | 88,500 | 2,980 Q1 | 268,900 | 2,980

The difference between the second and third columns in the quarter row is exactly what illustrates the semi-additive concept. Use this very example in the interview.

Build dynamic segmentation with a parameter.

▾Advanced

Segment customers by their total purchases in a way that the segments are recalculated when the time filter changes.

-- Helper table Segment: Name, MinValue, MaxValue

Customers in Segment =
VAR Segments = VALUES(Segment[Name])
RETURN
SUMX(
    Segments,
    VAR minV = CALCULATE(MIN(Segment[MinValue]))
    VAR maxV = CALCULATE(MIN(Segment[MaxValue]))
    RETURN
        COUNTROWS(
            FILTER(
                VALUES(Customer[CustomerID]),
                VAR s = [Total Sales]
                RETURN NOT ISBLANK(s) && s >= minV && s < maxV
            )
        )
)
Segment | Customers in Segment ---------+--------------------- Gold | 180 Silver | 940 Bronze | 2,110

The difference from a calculated column is that a column is fixed at load time, while this measure re-segments with every choice of time range. This is one of the senior-level DAX questions.

What is a virtual relationship with TREATAS?

▾Advanced
Budget vs Actual =
VAR ActualByCity =
    CALCULATE(
        SUM(Budget[Amount]),
        TREATAS(VALUES(Customer[City]), Budget[City])
    )
RETURN ActualByCity

When two tables have different cardinality or grain (for example a budget at month and city level, sales at transaction level), instead of building a risky physical relationship you transfer the filter with TREATAS.

Its advantage over a many-to-many relationship is that the direction and scope of the filter transfer are completely explicit and controlled.

How do you fix wrong subtotals in a matrix?

▾Advanced

Measures that involve ratios, ranks or conditions are recalculated over everything in the total row and don't match the sum of the rows above.

Correct Total =
IF(
    ISINSCOPE(Product[ProductName]),
    [Weighted Score],
    SUMX(VALUES(Product[ProductName]), [Weighted Score])
)
Product | Weighted Score | Correct Total -----------+----------------+-------------- Headphones | 0.42 | 0.42 Keyboard | 0.31 | 0.31 Total | 0.18 | 0.73

ISINSCOPE detects which level of the hierarchy we are at. At the detail level it uses the same measure, and at the total level it aggregates manually over the items.

How do you optimize a slow measure?

▾Advanced
  1. In DAX Studio, use Server Timings to see what percentage of time is in the Formula Engine; a high FE share means row-by-row iteration.
  2. Replace FILTER on the fact table with a column filter.
  3. Remove nested iterators; a SUMX inside a SUMX can usually be rewritten.
  4. Put repeated calculations in a VAR.
  5. Move heavy calculated columns to Power Query or the source.
  6. Reduce the cardinality of filter columns.
  7. Avoid DISTINCTCOUNT on high-cardinality columns; if possible, replace it with a numeric key column.

The concluding line: “Most DAX performance problems are really modeling problems, not formula problems.”

Why does a circular dependency error occur?

▾Advanced

When two calculated columns depend on each other, or when a calculated column uses a measure that itself depends on that column.

-- A dangerous pattern: a calculated column that calls a measure
Customer Tier = IF([Total Sales] > 1000000, "High", "Low")

Solutions: turn the logic into a measure, move the calculation to Power Query, or break the dependency with CALCULATE and an explicit filter.

Preventive rule: don't call measures in a calculated column. If you have to, that thing probably shouldn't be a column at all.

Build a calculated table with SUMMARIZECOLUMNS.

▾Advanced
EVALUATE
SUMMARIZECOLUMNS(
    Customer[City],
    'Date'[Year],
    FILTER(ALL(Sales[Status]), Sales[Status] = "paid"),
    "Revenue",   [Total Sales],
    "Orders",    COUNTROWS(Sales),
    "AOV",       DIVIDE([Total Sales], COUNTROWS(Sales))
)
ORDER BY [Revenue] DESC
City | Year | Revenue | Orders | AOV --------+------+-------------+--------+-------- Tehran | 1403 | 183,000,000 | 1,120 | 163,392 Mashhad | 1403 | 58,000,000 | 402 | 144,278

This is what Power BI generates behind every table visual. If you say you capture a visual's query with DAX Studio and run and optimize it directly, you've shown hands-on command of the tool.

Write a basket analysis counting customers shared between two products.

▾Advanced
Customers Buying Both =
VAR CustA =
    CALCULATETABLE(
        VALUES(Sales[CustomerID]),
        Product[ProductName] = "Headphones"
    )
VAR CustB =
    CALCULATETABLE(
        VALUES(Sales[CustomerID]),
        Product[ProductName] = "Keyboard"
    )
RETURN
    COUNTROWS(INTERSECT(CustA, CustB))
318

The set functions INTERSECT, UNION and EXCEPT are very useful for overlap, churn and returning-customer analyses, and few candidates use them in interviews.

Compute the churn rate and returning customers with DAX.

▾Advanced
Active Customers = DISTINCTCOUNT(Sales[CustomerID])

Returning Customers =
VAR CurrentPeriod = VALUES(Sales[CustomerID])
VAR PriorPeriod =
    CALCULATETABLE(
        VALUES(Sales[CustomerID]),
        DATEADD('Date'[Date], -1, MONTH)
    )
RETURN COUNTROWS(INTERSECT(CurrentPeriod, PriorPeriod))

New Customers = [Active Customers] - [Returning Customers]

Churn Rate =
VAR PriorCount =
    CALCULATE([Active Customers], DATEADD('Date'[Date], -1, MONTH))
RETURN DIVIDE(PriorCount - [Returning Customers], PriorCount)
Month | Active | Returning | New | Churn Rate --------+--------+-----------+-----+----------- Feb | 1,450 | 620 | 830 | 48.2% Mar | 1,610 | 910 | 700 | 37.2%

Clarify the definition of churn with the stakeholder before writing the formula: one month of inactivity, or three? This very question is the difference between an analyst and a report builder.

Which three common DAX mistakes is the interviewer looking for?

▾Advanced
  1. Building a calculated column instead of a measure. The result: a bloated model and non-dynamic calculations.
  2. Using FILTER on the fact table. The result: millions of rows scanned in the Formula Engine.
  3. Ignoring the date table. The result: time intelligence functions return wrong or empty results.

Three more that are mentioned less often: relying on bidirectional relationships instead of fixing the model, writing measures on the fact table instead of a dedicated measures table, and forgetting how BLANK behaves in percentage calculations.

If you say this list calmly and with reasons, you effectively show that you've made these mistakes yourself and learned from them — which is exactly what they want to hear.

Build a calculation group and say what problem it solves.

▾Advanced

Instead of building YTD, PY and YoY versions of every metric, you define the time logic once and it applies to every metric.

-- In Tabular Editor: a calculation group named "Time Intelligence"
Current      : SELECTEDMEASURE ()
YTD          : CALCULATE ( SELECTEDMEASURE (), DATESYTD ( 'Date'[Date] ) )
PY           : CALCULATE ( SELECTEDMEASURE (), SAMEPERIODLASTYEAR ( 'Date'[Date] ) )
YoY          : SELECTEDMEASURE () - CALCULATE ( SELECTEDMEASURE (),
                                    SAMEPERIODLASTYEAR ( 'Date'[Date] ) )
YoY %        : DIVIDE (
                 SELECTEDMEASURE () - CALCULATE ( SELECTEDMEASURE (),
                    SAMEPERIODLASTYEAR ( 'Date'[Date] ) ),
                 CALCULATE ( SELECTEDMEASURE (), SAMEPERIODLASTYEAR ( 'Date'[Date] ) ) )

With ten metrics and five time variants, instead of fifty measures you have only ten plus one calculation group.

An important limitation: the number format must be handled with FormatStringExpression, otherwise “YoY %” is shown in a currency format. And several calculation groups at once can interfere with each other, so set their precedence.

Implement dynamic row-level security (dynamic RLS).

▾Advanced
-- A UserAccess(Email, City) table in the model
-- RLS rule on the Customer table:
Customer[City] IN
    CALCULATETABLE (
        VALUES ( UserAccess[City] ),
        UserAccess[Email] = USERPRINCIPALNAME ()
    )

-- For an organizational hierarchy, with a path:
PATHCONTAINS (
    LOOKUPVALUE ( Employee[Path], Employee[Email], USERPRINCIPALNAME () ),
    Employee[EmployeeKey]
)
  • The access table must be hidden from user filters and not visible from the model.
  • Always test with the View as option in Desktop.
  • RLS is affected by filter direction; bidirectional relationships can bypass security.
If a user isn't in the access table, they see no rows at all and may think the report is broken. Put a help message on the page using ISEMPTY telling them to contact the data owner.

Solve many-to-many relationships and filter path ambiguity in DAX.

▾Advanced

When two tables are connected by more than one path, Power BI deactivates one of the relationships to avoid ambiguity. Solutions:

-- Temporarily activate an inactive relationship
Sales by Ship Date =
CALCULATE ( [Total Sales], USERELATIONSHIP ( 'Date'[Date], Sales[ShipDate] ) )

-- Pass a filter through a bridge table without a physical relationship
Sales via Bridge =
CALCULATE ( [Total Sales],
    TREATAS ( VALUES ( BridgeTable[ProductKey] ), Sales[ProductKey] ) )

The “role-playing date” pattern (order, ship and delivery dates) is solved with USERELATIONSHIP. But if users are supposed to choose between these dates in a slicer, building several separate date tables or using a field parameter gives a better experience.

Write a cohort retention analysis in DAX.

▾Advanced
-- Calculated column on Customer: month of first purchase
Cohort Month =
VAR firstDate =
    CALCULATE ( MIN ( Sales[OrderDate] ), ALLEXCEPT ( Customer, Customer[CustomerKey] ) )
RETURN EOMONTH ( firstDate, -1 ) + 1

-- Measure: retention in month n after the cohort
Retention =
VAR CohortSize =
    CALCULATE ( DISTINCTCOUNT ( Customer[CustomerKey] ),
                REMOVEFILTERS ( 'Date' ) )
VAR ActiveNow = DISTINCTCOUNT ( Sales[CustomerID] )
RETURN DIVIDE ( ActiveNow, CohortSize )
Cohort | M0 | M1 | M2 | M3 ---------+-------+-------+-------+------ 2024-04 | 100% | 32.0% | 24.1% | 19.4% 2024-05 | 100% | 35.1% | 27.6% | 22.8%

Build the “month number” column as a calculated column too: the difference between the order month and the cohort month. Then put it on the matrix columns to build the classic retention triangle.

Write a measure based on “the last date with data”.

▾Advanced
-- The problem: the date table runs to the end of the year, but data only goes up to today
Last Data Date =
CALCULATE ( MAX ( Sales[OrderDate] ), REMOVEFILTERS () )

Sales To Date =
VAR LastDate = [Last Data Date]
RETURN
CALCULATE ( [Total Sales], KEEPFILTERS ( 'Date'[Date] <= LastDate ) )

MTD Comparable =
VAR LastDate = [Last Data Date]
VAR DayNo    = DAY ( LastDate )
RETURN
CALCULATE ( [Total Sales],
    DATESMTD ( 'Date'[Date] ),
    KEEPFILTERS ( 'Date'[DayOfMonth] <= DayNo ) )

The third measure fixes a common problem: comparing the current month (not yet over) with the full previous month always shows a false drop. Limiting the previous month to the same number of days makes the comparison fair.

Power Query and the M language

Power Query is the data preparation layer in Excel and Power BI. Two topics matter most in this section: how graphical steps turn into M code, and the concept of query folding, which makes the difference between a fast query and a slow one.

M language review sheet

The functions you'll need most in the Advanced Editor.M
let Step1 = ..., Step2 = ... in Step2
Basic structure; each step is a variable
Table.SelectRows(t, each [Amount] > 0)
Row filter
Table.SelectColumns(t, {"A","B"})
Keep specific columns
Table.AddColumn(t, "New", each [A]*[B], type number)
New column with an explicit type
Table.TransformColumnTypes(t, {{"Date", type date}})
Set column types
Table.Group(t, {"City"}, {{"Rev", each List.Sum([Amount])}})
Grouping and aggregation
Table.NestedJoin(a, {"id"}, b, {"id"}, "new", JoinKind.LeftOuter)
Merging two queries
Table.Combine({a, b})
Vertical append
Table.UnpivotOtherColumns(t, {"City"}, "Attribute", "Value")
Wide to long
Table.Pivot(...)
Long to wide
Table.RemoveRowsWithErrors(t)
Remove rows with errors
try expr otherwise null
Value-level error handling
Text.Trim · Text.Upper · Text.Split
Text functions
Date.StartOfMonth · Date.AddDays
Date functions
List.Sum · List.Average · List.Distinct
List functions
Table.Buffer(t) · List.Buffer(l)
Pins data in memory; breaks folding

Basic level 10

What is Power Query and how does it differ from DAX?

▾Basic
Power Query (M)DAX
When it runsAt load and refresh timeWhen the visual is displayed
JobExtracting, cleaning, shapingCalculation and aggregation
Letter caseCase-sensitiveCase-insensitive
Mental modelStep by step, like ETLContext-driven

The decision rule: do whatever you can in the source, what you can't in Power Query, and only the calculations that depend on user selections in DAX.

Explain the structure of an M query.

▾Basic
let
    Source        = Csv.Document(File.Contents("C:\data\orders.csv"),
                        [Delimiter=",", Encoding=65001]),
    Promoted      = Table.PromoteHeaders(Source, [PromoteAllScalars=true]),
    Typed         = Table.TransformColumnTypes(Promoted, {
                        {"order_id", Int64.Type},
                        {"order_date", type date},
                        {"amount", type number}
                    }),
    Filtered      = Table.SelectRows(Typed, each [amount] > 0),
    AddedMonth    = Table.AddColumn(Filtered, "Month",
                        each Date.StartOfMonth([order_date]), type date)
in
    AddedMonth

Each step is a variable that references the previous step. Give steps meaningful names; you'll feel the difference when maintaining it six months later. Encoding=65001 means UTF-8, which is essential for non-Latin text such as Persian.

What is the difference between Merge and Append?

▾Basic
// Merge: adds columns from another table (like a JOIN)
Merged = Table.NestedJoin(Orders, {"customer_id"},
                          Customers, {"customer_id"},
                          "cust", JoinKind.LeftOuter),
Expanded = Table.ExpandTableColumn(Merged, "cust", {"name", "city"}),

// Append: stacks rows (like UNION ALL)
Appended = Table.Combine({Sales2023, Sales2024})

After a merge, always compare the row counts before and after. If the right-hand table has no unique key, rows are multiplied and the totals go wrong.

Where do you open Power Query, and how are steps recorded?

▾Basic

In Excel from the Data tab via Get Data, and in Power BI via Transform Data. Everything you do with the mouse is recorded as a step in the Applied Steps pane and turned into M code behind the scenes.

Applied Steps Source Promoted Headers Changed Type Filtered Rows Removed Columns Added Custom Column

You can move, delete or edit steps. Double-click a step to rename it and give it a meaningful name; six months later, when the query fails, “Filtered Rows2” tells you nothing.

Which Power Query transformations are used most?

▾Basic
ActionPath
First row as headersHome → Use First Row as Headers
Change column typeClick the type icon next to the column name
Remove extra columnsSelect the needed columns → Remove Other Columns
Remove blank and duplicate rowsHome → Remove Rows
Split columnSplit Column by Delimiter
Replace valuesTransform → Replace Values
Custom columnAdd Column → Custom Column
GroupingTransform → Group By

Practical advice: remove unneeded columns at the very start of the query, not at the end. The sooner the data volume shrinks, the faster every later step runs.

Write a simple custom column in M.

▾Basic
// Add Column → Custom Column
= [Qty] * [UnitPrice]

// With a condition
= if [Amount] >= 1000000 then "large"
  else if [Amount] >= 300000 then "medium"
  else "small"

// Text and dates
= Text.Upper([City])
= Date.StartOfMonth([OrderDate])
= Date.Year([OrderDate])
The M language is case-sensitive: Text.Upper is correct and text.upper raises an error. Also, an if in M must always have an else, unlike Excel, where the third argument of IF is optional.

Why do column data types matter?

▾Basic

Until a column's type is correct, neither calculations, nor sorting, nor model relationships work properly.

Typed = Table.TransformColumnTypes(Source, {
    {"order_id",   Int64.Type},
    {"order_date", type date},
    {"amount",     type number},
    {"city",       type text}
})

The automatic “Changed Type” step that Power Query adds at the start hard-codes the column names. If the source later renames a column, this step breaks — and it is the most frequent cause of refresh errors in real environments.

What kinds of data sources does Power Query support?

▾Basic
GroupExampleNote
FileExcel, CSV, JSON, XML, PDF, folderA folder for combining several files with the same structure
DatabasesSQL Server, Postgres, Oracle, MySQLThey have the best folding
Online servicesSharePoint, Dataverse, D365, Google AnalyticsRequest rate limits
WebAPIs with OData or JSON, HTML tablesWatch out for authentication and pagination

When connecting to a database, there is an option to write a SQL query by hand. If you use it, folding is broken from the start, so only use it when the logic really can't be expressed in Power Query.

Build an index column and a conditional column from the UI.

▾Basic
// Add Column → Index Column
Indexed = Table.AddIndexColumn(Source, "Index", 1, 1, Int64.Type),

// Add Column → Conditional Column
Banded = Table.AddColumn(Indexed, "Band", each
    if [Amount] >= 1000000 then "large"
    else if [Amount] >= 300000 then "medium"
    else "small", type text),

// Add Column → Column From Examples  (it guesses the pattern)
Domain = Table.AddColumn(Banded, "Domain",
    each Text.AfterDelimiter([Email], "@"), type text)

An index column has an important use: after sorting, it pins the numbering, and you can use it for grouping or removing duplicates. But remember that creating it breaks folding, so add it as late in the chain as possible.

How do you fix a “column not found” error after the source changes?

▾Basic

The culprit is usually the automatic Changed Type step, which has hard-coded the column names.

// Fragile
Table.TransformColumnTypes(Source, {{"Order Number", Int64.Type}})

// More robust: don't error if the column is missing
Table.TransformColumnTypes(Source, {{"Order Number", Int64.Type}}, "fa-IR")
Table.RenameColumns(Source, {{"Order Number","order_id"}}, MissingField.Ignore)
Table.SelectColumns(Source, {"order_id","amount"}, MissingField.UseNull)

In the Power Query settings you can turn off automatic type detection. Then you add an explicit, controlled typing step yourself in the right place, and the query becomes more robust to source changes.

Intermediate level 11

What is query folding and why is it vital?

▾Intermediate

Power Query tries to translate your steps into the source's language (for example SQL) and hand the processing to the server. If it succeeds, only the final result is transferred.

// Folds: translated into WHERE and GROUP BY in SQL
Table.SelectRows(Source, each [Date] >= #date(2024,1,1))

// Breaks folding: custom logic with no SQL equivalent
Table.AddColumn(Source, "X", each MyCustomFunction([A]))

Right-click a step and choose View Native Query to see how far it has folded. If the option is grayed out, folding is broken.

Broken folding means all the data is pulled from the server and then filtered in memory. On large tables this is the difference between a few seconds and a few hours. Always put folding-breaking steps at the end of the chain.

Unpivot the monthly columns.

▾Intermediate
// Input: City | Jan | Feb | Mar   (report layout)
Unpivoted = Table.UnpivotOtherColumns(Source, {"City"}, "Month", "Amount"),
Typed     = Table.TransformColumnTypes(Unpivoted, {{"Amount", type number}})
City | Month | Amount → City | Jan | Feb | Mar --------+-------+-------- --------+-----+-----+---- Tehran | Jan | 50000000 Tehran | ... | ... | ... Tehran | Feb | 62000000

Always prefer UnpivotOtherColumns to UnpivotColumns: if a new month is added to the source file, the first one automatically picks it up too, but the second breaks.

How is grouping with several aggregations written in Power Query?

▾Intermediate
Grouped = Table.Group(Source, {"City", "Month"}, {
    {"Revenue",   each List.Sum([Amount]),            type number},
    {"Orders",    each Table.RowCount(_),             Int64.Type},
    {"Customers", each List.Count(List.Distinct([customer_id])), Int64.Type},
    {"MaxOrder",  each List.Max([Amount]),            type number}
})
City | Month | Revenue | Orders | Customers | MaxOrder --------+--------+------------+--------+-----------+--------- Tehran | 1403-05| 71,000,000 | 412 | 338 | 2,500,000

The _ symbol refers to the whole group table and [Amount] to one of its columns. This distinction is the source of most errors when writing Table.Group by hand.

Read all the files in a folder at once.

▾Intermediate
let
    Source   = Folder.Files("C:\reports\monthly"),
    OnlyCsv  = Table.SelectRows(Source, each Text.EndsWith([Name], ".csv")),
    Parsed   = Table.AddColumn(OnlyCsv, "Data",
                   each Table.PromoteHeaders(
                        Csv.Document([Content], [Encoding=65001]))),
    Selected = Table.SelectColumns(Parsed, {"Name", "Data"}),
    Expanded = Table.ExpandTableColumn(Selected, "Data",
                   {"order_id", "amount", "city"})
in
    Expanded

Keeping the Name column is a good trick: later you can tell which file each row came from, which is vital for troubleshooting inconsistent data.

How do you handle errors in Power Query?

▾Intermediate
// Replace the erroneous value
Safe = Table.AddColumn(Source, "Amount_clean",
           each try Number.From([amount_text]) otherwise null, type number),

// Keep the error message for review
Diag = Table.AddColumn(Source, "Err",
           each try Number.From([amount_text]),
       [ErrorMessage = if [Err][HasError] then [Err][Error][Message] else null]),

// Remove rows with errors
Clean = Table.RemoveRowsWithErrors(Source, {"amount"})

Before removing, use “Keep Errors” and look at the rows with errors. There is often a clear pattern, such as a thousands separator or Persian digits, which should be fixed at the root rather than by deleting.

What are parameters in Power Query used for?

▾Intermediate
// Parameters: ServerName, DatabaseName, RangeStart, RangeEnd
let
    Source = Sql.Database(ServerName, DatabaseName),
    Sales  = Source{[Schema="dbo", Item="Sales"]}[Data],
    Filtered = Table.SelectRows(Sales,
        each [OrderDate] >= RangeStart and [OrderDate] < RangeEnd)
in
    Filtered

Uses: switching between development and production environments without editing the query, a configurable file path, and a prerequisite for incremental refresh (which needs exactly two parameters with these names).

Name the join kinds in a merge.

▾Intermediate
TypeResult
LeftOuterAll left rows, with right-side matches
RightOuterThe mirror image
FullOuterAll rows from both sides
InnerMatches only
LeftAntiLeft rows that aren't in the right
RightAntiThe mirror image
Missing = Table.NestedJoin(Orders, {"customer_id"}, Customers, {"customer_id"},
                           "m", JoinKind.LeftAnti)

The two anti joins have no direct equivalent in SQL and must be written with NOT EXISTS. They are very useful for data quality control: a LeftAnti merge tells you immediately how many orders have unmatched customers.

Read paginated API data in Power Query.

▾Intermediate
let
    GetPage = (page as number) as table =>
        let
            raw  = Json.Document(Web.Contents("https://api.example.com/orders",
                        [Query = [page = Text.From(page), per_page = "500"]])),
            rows = Table.FromRecords(raw[items])
        in
            rows,
    First  = Json.Document(Web.Contents("https://api.example.com/orders",
                 [Query = [page = "1", per_page = "500"]])),
    Total  = Number.RoundUp(First[total] / 500),
    Pages  = List.Transform({1..Total}, each GetPage(_)),
    All    = Table.Combine(Pages)
in
    All

Always pass the parameters in the Query record, not by concatenating strings onto the URL; only then does Power BI correctly recognize the base URL for authentication, and the query can also be refreshed in the service.

What do privacy levels do?

▾Intermediate

Each source gets one of three levels: Public, Organizational or Private. Power Query prevents data leaking between sources with incompatible levels.

  • If a source is Private, its values can't be used in a query sent to another source.
  • The result: the engine is forced to process everything locally, and folding breaks completely.
  • The symptom is sudden, unexplained slowness after adding a new source.

If all sources are inside the organization and trustworthy, you can set the levels to Organizational or temporarily disable the privacy check. This is one of the least-known causes of performance drops.

Expand nested columns with Table.ExpandTableColumn.

▾Intermediate
Merged   = Table.NestedJoin(Orders, {"customer_id"}, Customers, {"customer_id"},
                            "cust", JoinKind.LeftOuter),
Expanded = Table.ExpandTableColumn(Merged, "cust",
              {"name", "city", "segment"},
              {"customer_name", "customer_city", "customer_segment"})

The third argument is the names of the columns inside the nested table and the fourth is the names you want in the output.

If the right-hand table has several rows for one key, the number of rows grows after the expand. After every merge, compare the counts before and after with Table.RowCount.

Organize several queries with Reference and Duplicate.

▾Intermediate
  • Duplicate makes an independent copy; changes to the original don't affect the copy.
  • Reference creates a new query that takes the first query's output as its input; changes to the original affect all of them.
// Base query: Orders_Base  (connection only, not loaded)
let Source = Sql.Database("srv","dwh"),
    T = Source{[Schema="dbo", Item="Orders"]}[Data],
    Clean = Table.SelectRows(T, each [Status] = "paid")
in  Clean

// Derived query: Orders_Monthly
let S = Orders_Base,
    G = Table.Group(S, {"Month"}, {{"Rev", each List.Sum([Amount]), type number}})
in  G

The right pattern: one base query with “Enable load” turned off, and several derived queries that are loaded. This way the cleaning logic is written once and the source is read only once.

Advanced level 9

Write a custom function in M.

▾Advanced
// New query named fnCleanPersian
let
    fn = (input as nullable text) as nullable text =>
        if input = null then null
        else
            let
                t1 = Text.Replace(input, "ي", "ی"),
                t2 = Text.Replace(t1, "ك", "ک"),
                t3 = Text.Replace(t2, "‌", " "),
                t4 = Text.Trim(Text.Clean(t3))
            in
                t4
in
    fn
// Use in the main query
Cleaned = Table.TransformColumns(Source, {{"city", fnCleanPersian, type text}})

Declaring the input and output types (as nullable text) is optional but surfaces errors earlier. Unifying Arabic and Persian character variants is almost always necessary in Iranian data.

Build a date table in M.

▾Advanced
let
    StartDate = #date(2021, 1, 1),
    EndDate   = Date.EndOfYear(DateTime.Date(DateTime.LocalNow())),
    DayCount  = Duration.Days(EndDate - StartDate) + 1,
    Dates     = List.Dates(StartDate, DayCount, #duration(1,0,0,0)),
    AsTable   = Table.FromList(Dates, Splitter.SplitByNothing(), {"Date"}),
    Typed     = Table.TransformColumnTypes(AsTable, {{"Date", type date}}),
    WithCols  = Table.AddColumn(Typed, "Year",      each Date.Year([Date]), Int64.Type),
    WithCols2 = Table.AddColumn(WithCols, "MonthNo", each Date.Month([Date]), Int64.Type),
    WithCols3 = Table.AddColumn(WithCols2, "MonthName", each Date.MonthName([Date]), type text),
    WithCols4 = Table.AddColumn(WithCols3, "Quarter", each "Q" & Text.From(Date.QuarterOfYear([Date])), type text),
    WithCols5 = Table.AddColumn(WithCols4, "IsWorkday", each Date.DayOfWeek([Date], Day.Saturday) < 5, type logical)
in
    WithCols5

Building the date table in M is preferable to DAX because the calculation load is taken off the model engine, and the same query can be shared across several reports as a dataflow.

A query refresh has become very slow. What do you check?

▾Advanced
  1. See how far folding is preserved (View Native Query on the last step).
  2. Move folding-breaking steps (custom columns, merges with another source, Index) as close to the end as possible.
  3. Remove unneeded columns at the very start of the query, not at the end.
  4. Use Table.Buffer only when it is really necessary, because it breaks folding itself.
  5. Review multiple merges; each merge can read the source several times.
  6. Check the privacy levels; combining sources with incompatible levels disables folding completely.

The last point is less well known and a common cause of strange slowdowns. Mentioning it shows you've dealt with real production problems.

Expand a nested JSON column in Power Query.

▾Advanced
let
    Source   = Table.FromColumns({{"{""user"":{""id"":1,""city"":""Tehran""},""amt"":500}"}}, {"raw"}),
    Parsed   = Table.AddColumn(Source, "obj", each Json.Document([raw])),
    Expanded = Table.ExpandRecordColumn(Parsed, "obj", {"user", "amt"}),
    User     = Table.ExpandRecordColumn(Expanded, "user", {"id", "city"},
                                        {"user_id", "user_city"})
in
    User
raw | amt | user_id | user_city ---------------+-----+---------+---------- {"user":{...}} | 500 | 1 | Tehran

If the JSON structure differs between rows, ExpandRecordColumn with fixed names breaks. In that case you need to extract the field names dynamically: Record.FieldNames and List.Union.

What do you do in Power Query and what do you do in the source?

▾Advanced

Order of priority, from the bottom up:

  1. Source (SQL / data warehouse): broad filtering, JOINs, heavy aggregation, business logic shared by several consumers.
  2. Dataflow: cleaning shared across several reports, organizational reference tables.
  3. Power Query in the file: shaping specific to that report, renaming and retyping, unpivot.
  4. DAX: only calculations that depend on user selections.

This hierarchy is a standard answer that shows you think about architecture, not just about solving the problem in this one file.

Write a repeated transformation with List.Accumulate.

▾Advanced
// Replace a set of characters in one pass
let
    Replacements = {
        {"ي","ی"}, {"ك","ک"}, {"‌"," "}, {"  "," "}
    },
    Clean = (txt as nullable text) as nullable text =>
        if txt = null then null
        else List.Accumulate(Replacements, txt,
             (state, cur) => Text.Replace(state, cur{0}, cur{1})),
    Applied = Table.TransformColumns(Source, {{"city", Clean, type text}})
in
    Applied

The List.Accumulate function is the equivalent of reduce: it takes an initial value and updates it for each element of the list. A clean replacement for a chain of ten nested Text.Replace calls.

Process columns dynamically, without hard-coded names.

▾Advanced
let
    // Change the type of every column starting with "amt_"
    numCols = List.Select(Table.ColumnNames(Source), each Text.StartsWith(_, "amt_")),
    typed   = Table.TransformColumnTypes(Source,
                 List.Transform(numCols, each {_, type number})),

    // Trim spaces in every text column
    txtCols = List.Select(Table.ColumnNames(typed),
                 each Type.Is(Value.Type(Table.Column(typed, _){0}), type text)),
    trimmed = Table.TransformColumns(typed,
                 List.Transform(txtCols, each {_, Text.Trim, type text}))
in
    trimmed

This pattern makes the query robust to renamed or added columns. For pipelines whose source isn't fixed, this is the difference between a query that breaks every month and one that runs for years.

How do you debug Power Query steps?

▾Advanced
  1. Click each step and look at the preview of its result; find the step where the problem starts.
  2. Turn on Column Quality and Column Distribution to see the percentage of errors and blanks in each column.
  3. On the column with errors, use Keep Errors so only the faulty rows remain.
  4. Right-click and use View Native Query to see how far folding is preserved.
  5. In the Advanced Editor, give the steps meaningful names so the code is easier to read.
  6. For testing, add a temporary Table.FirstN(Source, 1000) step so runs are fast, and remove it at the end.

The Power Query preview only works on the first thousand rows, so a problem that isn't visible in the preview may appear in a full load. If an error only happens during refresh, this is the first thing to check.

Unify columns from two sources that use different names.

▾Advanced
let
    // Mapping table: source name -> standard name
    Map = #table({"from","to"}, {
        {"OrderNo","order_id"}, {"Order Number","order_id"},
        {"CustNo","customer_id"}, {"Amt","amount"}, {"Total","amount"}
    }),
    Pairs = List.Zip({Map[from], Map[to]}),
    Renamed = Table.RenameColumns(Source, Pairs, MissingField.Ignore),
    // Keep only the standard columns, throw away the rest
    Wanted = {"order_id","customer_id","amount","order_date"},
    Final  = Table.SelectColumns(Renamed, Wanted, MissingField.UseNull)
in
    Final

Two key options: MissingField.Ignore means the rename doesn't error if a column isn't in this source; and MissingField.UseNull creates the missing column with empty values so the output structure is always the same. This pattern makes it possible to combine several files with inconsistent formats.

Data Warehouse

This section is the bridge between the operational database and the reporting layer: normalization, star schemas, types of fact and dimension tables, managing history with SCDs, and architecture decisions. For BI and data engineering roles, these concepts carry the most conceptual weight.

Data warehouse review sheet

Terms you should be able to define without pausingSQL
1NF · 2NF · 3NF
Atomic values · full dependency on the key · no transitive dependency
Star Schema
One fact in the center, flat dimensions around it
Snowflake Schema
Dimensions normalized into several tables
Grain
The exact meaning of one row in a fact table
Surrogate Key
A meaningless artificial key, independent of the source system
Natural / Business Key
The real business identifier, such as a national ID or product code
SCD Type 1 / 2 / 3
Overwrite · new row with a validity period · previous-value column
Conformed Dimension
A shared dimension with the same definition across several facts
Degenerate Dimension
An identifier such as an invoice number that stays in the fact
Junk Dimension
Combining several low-value flags into one dimension
Bridge Table
Resolving a many-to-many relationship between a fact and a dimension
Factless Fact
A fact with no numeric measure; records an event or coverage
Additive / Semi / Non-additive
Whether a measure is additive across all dimensions, some or none
Kimball vs Inmon
Bottom-up and dimensional · top-down and normalized
Data Vault
Hub, Link, Satellite; auditable and resilient to change
ODS / Staging / Data Mart
Operational data store · temporary staging area · subject-specific slice
Slowly Changing Fact
Correcting a fact row after it has been loaded
Late Arriving Dimension
A fact arrives before its dimension; create a placeholder row

Basic level 13

What is a data warehouse, and why can't you report directly from the operational database?

▾Basic

A data warehouse is a subject-oriented, integrated, time-variant and non-volatile database built for analysis.

  • Performance: heavy analytical queries slow the operational system down and disrupt users' daily work.
  • Data model: the normalized operational model isn't optimized for analytical JOINs.
  • History: the operational system keeps the current state; historical reports need the state at the time.
  • Integration: data comes from several systems and must be combined under one definition.

The key sentence: “The operational system answers what is the state right now; the data warehouse answers what happened over time.”

Explain the first three normal forms with an example.

▾Basic
Original table (unnormalized)
order_id | customer | phones          | city   | city_province
---------+----------+-----------------+--------+--------------
   9001  | Maryam   | 0912..., 0935...| Tehran | Tehran
  • 1NF — each cell holds one atomic value. The phones column must move to a separate table.
  • 2NF — with a composite key, no non-key column may depend on only part of the key.
  • 3NF — a non-key column must not depend on another non-key column. city_province depends on city, so it must move to a city table.
-- After normalization
orders(order_id, customer_id, city_id, order_date)
customers(customer_id, name)
customer_phones(customer_id, phone)
cities(city_id, city_name, province)

Memorizing the definitions isn't enough; in the interview you're usually handed a bad table and asked to normalize it. Practice saying, for each step, “which dependency causes the problem and why”.

What is a star schema and what are its parts?

▾Basic
        DimDate         DimCustomer
             \             /
              \           /
DimProduct ── FactSales ── DimStore
                  |
            DimPromotion

FactSales: DateKey, CustomerKey, ProductKey, StoreKey, PromoKey,
           Quantity, GrossAmount, DiscountAmount, NetAmount, CostAmount
  • Fact: numeric measures and foreign keys to the dimensions; many rows, few columns.
  • Dimension: descriptive attributes for filtering and grouping; few rows, many columns.

A quick test to tell them apart: what you sum is a measure and sits in the fact; what you group by is an attribute and sits in a dimension.

What is the difference between a star schema and a snowflake schema?

▾Basic
StarSnowflake
DimensionsFlat and denormalizedNormalized into several tables
Number of JOINsFewMany
Query speedHigherLower
RedundancyYesNo
Ease of understanding for usersSimpleComplex

In practice the star schema is the default. A snowflake is only justified when a dimension is very large and highly repetitive, or when a hierarchy is shared by several dimensions and is easier to maintain in one place.

What does grain mean, and why is it the first design decision?

▾Basic

Grain means “what exactly does one row of this fact table represent”. For example “one line item of an invoice” or “the stock of one product in one warehouse at the end of each day”.

-- Grain: one row = one invoice line
FactSalesLine(InvoiceKey, LineNo, DateKey, CustomerKey, ProductKey,
              Quantity, NetAmount)

Until the grain is defined, you can't decide which dimensions connect to the fact or which measures can be recorded.

Two fatal mistakes: mixing two grains in one table (for example invoice lines and invoice totals side by side), and choosing a coarser grain than needed. A finer grain can always be aggregated, but details can't be rebuilt from a coarse grain.

What is a surrogate key and why is it used?

▾Basic
CREATE TABLE DimCustomer (
    CustomerKey   bigserial PRIMARY KEY,   -- surrogate key
    CustomerID    varchar(20) NOT NULL,    -- natural key from the source system
    Name          text,
    City          text,
    ValidFrom     date,
    ValidTo       date,
    IsCurrent     boolean
);
  • It makes it possible to keep several historical versions of one customer (a prerequisite for SCD Type 2).
  • It cuts the dependency on the source system; a change in the customer code format doesn't break the warehouse.
  • A small integer makes joins faster and more compact.
  • It makes it possible to merge data from several systems with different codes.

Always keep both keys: the surrogate for joining and the natural key for tracing back to the source and troubleshooting.

Why is a date table needed, and what columns does it have?

▾Basic
DimDate(
  DateKey int PRIMARY KEY,        -- 20240831
  FullDate date,
  Year int, Quarter int, Month int, MonthName text,
  Week int, DayOfWeek int, DayName text,
  IsWeekend boolean, IsHoliday boolean, HolidayName text,
  FiscalYear int, FiscalQuarter int, FiscalMonth int,
  JalaliYear int, JalaliMonth int, JalaliMonthName text
)
  • Calendar calculations move out of the queries and are computed once.
  • The fiscal calendar, other calendars (such as Solar Hijri) and public holidays get a place to live.
  • Days without transactions also show up in reports.

In an Iranian environment, having Solar Hijri columns and public holidays in this very table is the difference between a usable data warehouse and a merely translated one.

How do a data mart, an ODS and staging differ?

▾Basic
LayerContentData lifetime
StagingA raw copy of the source, no transformationTemporary, usually until the next run
ODSIntegrated, near-real-time, normalized dataCurrent state
Data WarehouseDimensional model, with history, organization-wideLong term
Data MartA subject-specific slice for one business unitLong term

A data mart can be built independently or derived from the central warehouse. Prefer the second, otherwise each unit builds its own version of the truth — the very problem the data warehouse was meant to solve.

What are audit columns in warehouse tables?

▾Basic
ALTER TABLE FactSales
  ADD COLUMN LoadDate      timestamptz DEFAULT now(),
  ADD COLUMN UpdateDate    timestamptz,
  ADD COLUMN SourceSystem  text,
  ADD COLUMN BatchID       bigint,
  ADD COLUMN RowHash       text;

These columns answer three questions: when and where did this row come from, in which run was it loaded, and has it changed since last time.

RowHash is a useful practical trick: by comparing hashes you update only the rows that really changed, and pointless writes disappear.

What should the naming convention in a data warehouse look like?

▾Basic
DimCustomer, DimProduct, DimDate        dimensions
FactSales, FactInventorySnapshot        facts
BrgProductCategory                      bridge table
stg_orders, stg_customers               staging layer
CustomerKey (surrogate) · CustomerID (natural)
IsCurrent, ValidFrom, ValidTo           standard SCD columns
  • A table-type prefix makes the model much easier to read for a newcomer.
  • The suffix Key for surrogate keys and ID for natural keys removes ambiguity.
  • Columns with the same meaning should have the same name in every table.

A naming convention seems like a small thing, but it earns points in an interview: it shows you've thought about whoever takes over this model six months from now.

Explain an OLAP cube and its operations.

▾Basic
  • Slice — fixing one dimension to one value; for example only the year 2024.
  • Dice — selecting a subset of several dimensions at once.
  • Drill down / up — moving through a hierarchy; year to quarter to month.
  • Roll up — aggregating to a higher level.
  • Pivot — rotating the axes and swapping rows and columns.
  • Drill through — going from an aggregate number to the detail rows behind it.

These terms come from the world of multidimensional cubes, but today the same concepts are used exactly in Power BI and every other BI tool; only the underlying implementation has changed.

What is the difference between row-based and columnar storage?

▾Basic
Row:     [1,Maryam,Tehran,850000][2,Kaveh,Mashhad,320000]
Column:  [1,2][Maryam,Kaveh][Tehran,Mashhad][850000,320000]
Row-basedColumnar
Reading one full rowFastSlow
Aggregating one columnSlowVery fast
CompressionPoorExcellent, because values of the same kind sit together
Best forOLTPData warehousing and analytics

Columnar compresses better because a column holds only one data type and often repeated values, so techniques such as dictionary encoding and run-length encoding work very well.

How are the access matrix and data ownership defined in a warehouse?

▾Basic
  • Data Owner — the business person responsible for approving the definition and correctness of the data.
  • Data Steward — operationally responsible for quality and documentation.
  • Data Custodian — the technical team that runs the infrastructure and access.
  • Consumer — a user with read access at a defined level.

Without a clear owner, nobody can approve the final definition of a metric, and the “what does revenue mean” debate starts over every six months. Assigning an owner is an organizational decision, not a technical one, but the data team should push for it.

Intermediate level 16

Explain the types of SCD with examples.

▾Intermediate
TypeBehaviorHistory
Type 0Never changes—
Type 1The value is overwrittenNo
Type 2A new row with a validity periodFull
Type 3A “previous value” columnOne step
Type 4A separate history tableFull
Type 6A combination of 1, 2 and 3Complete + quick access to the current value
-- SCD Type 2
CustomerKey | CustomerID | City   | ValidFrom  | ValidTo    | IsCurrent
------------+------------+--------+------------+------------+----------
       1001 |     C-102  | Tehran | 2023-01-01 | 2024-05-31 | false
       1450 |     C-102  | Karaj  | 2024-06-01 | 9999-12-31 | true

Choosing the type is a business decision, not a technical one. The right question is: “If a customer moves to another city, should last year's sales be attributed to Tehran or to Karaj?” The answer determines the SCD type.

Implement SCD Type 2 with MERGE.

▾Intermediate
-- Step 1: close the changed rows
UPDATE DimCustomer d
SET    ValidTo = CURRENT_DATE - 1,
       IsCurrent = false
FROM   stg_customers s
WHERE  d.CustomerID = s.CustomerID
  AND  d.IsCurrent
  AND  (d.City, d.Segment) IS DISTINCT FROM (s.City, s.Segment);

-- Step 2: insert a new version for those + new customers
INSERT INTO DimCustomer (CustomerID, Name, City, Segment, ValidFrom, ValidTo, IsCurrent)
SELECT s.CustomerID, s.Name, s.City, s.Segment,
       CURRENT_DATE, DATE '9999-12-31', true
FROM   stg_customers s
LEFT JOIN DimCustomer d
       ON d.CustomerID = s.CustomerID AND d.IsCurrent
WHERE  d.CustomerKey IS NULL;
UPDATE 37 INSERT 0 51

Using IS DISTINCT FROM is deliberate: with <>, if one of the values is NULL the condition is neither true nor false and the change is silently ignored. Exactly this detail separates a correct SCD implementation from a wrong one.

What are the types of fact table?

▾Intermediate
  • Transaction fact — one row per event. The finest-grained and most common.
  • Periodic snapshot — a snapshot of the state at the end of each period; inventory, account balance.
  • Accumulating snapshot — one row for the entire life cycle of a process, with date columns that fill in over time.
  • Factless fact — no numeric measure; records an event or coverage.
-- Accumulating snapshot for the order life cycle
FactOrderCycle(
  OrderKey, CustomerKey,
  OrderDateKey, ApprovedDateKey, ShippedDateKey, DeliveredDateKey,
  DaysToApprove, DaysToShip, DaysToDeliver, OrderAmount
)

Unlike the others, the third type is updated, and it is unbeatable for analyzing cycle times and process bottlenecks. Mentioning it in an interview is rare and earns points.

What do additive, semi-additive and non-additive measures mean?

▾Intermediate
TypeExampleBehavior
AdditiveSales amount, quantityCan be summed across every dimension
Semi-additiveInventory, account balanceCan be summed across every dimension except time
Non-additivePercentage, rate, ratio, unit priceCan't be summed at all
Month | SUM(Qty) | Closing Stock --------+----------+-------------- Jan | 91,200 | 3,140 Feb | 88,500 | 2,980 Q1 | 268,900 | 2,980 ← not the sum, but the last value

The fix for non-additive measures: store their components in the fact, not the ratio itself. Keep the numerator and denominator and compute the ratio in the measure layer, otherwise you get the average of percentages instead of the percentage of the total.

What is a conformed dimension and what problem does it solve?

▾Intermediate

A dimension shared by several fact tables with the same definition, keys and values. For example, the same DimDate and DimProduct connected to both sales and inventory.

-- Comparing sales and inventory is only possible thanks to shared dimensions
SELECT d.MonthName, p.Category,
       SUM(f.NetAmount) AS sales,
       AVG(i.OnHandQty)  AS avg_stock
FROM DimDate d
JOIN FactSales f     ON f.DateKey = d.DateKey
JOIN FactInventory i ON i.DateKey = d.DateKey AND i.ProductKey = f.ProductKey
JOIN DimProduct p    ON p.ProductKey = f.ProductKey
GROUP BY 1, 2;

This capability is called “drill across”: moving horizontally between several business processes. Without conformed dimensions, each fact becomes an island and comparisons between them are impossible.

What are degenerate and junk dimensions?

▾Intermediate
  • Degenerate dimension — an identifier such as an invoice or transaction number that has no descriptive attributes, so it needs no separate dimension table and stays directly in the fact.
  • Junk dimension — several low-value flags and statuses (was it a gift? payment method, channel), each not worth its own table, combined into one composite dimension.
DimOrderFlags(FlagKey, IsGift, PaymentMethod, Channel, IsFirstOrder)
-- Number of rows = product of the possible states, usually a few dozen rows
FactSales(..., FlagKey, InvoiceNumber /* degenerate */, ...)

Without a junk dimension, the fact table fills up with flag columns and the model becomes cluttered and slow. Few candidates know these two concepts.

How do you model a many-to-many relationship between a fact and a dimension?

▾Intermediate

Example: a patient receives several diagnoses in one visit, or a product belongs to several categories at once. The solution is a bridge table.

FactVisit(VisitKey, PatientKey, DateKey, DiagnosisGroupKey, Cost)
BrgDiagnosisGroup(DiagnosisGroupKey, DiagnosisKey, Weight)
DimDiagnosis(DiagnosisKey, DiagnosisName, Category)
The Weight column is essential: without it, joining through the bridge table double-counts the cost. With weights (which sum to one within each group) you can give both an allocated report and a full report, and state explicitly which one you're presenting.

What is a factless fact? Give two uses.

▾Intermediate
-- 1) Recording an event: a student attending a class
FactAttendance(DateKey, StudentKey, CourseKey, InstructorKey)

-- 2) Coverage: which product was available in which store
FactProductCoverage(DateKey, StoreKey, ProductKey, PromoKey)

They have no numeric measure, but the presence or absence of a row is meaningful. The second case makes it possible to answer “which products were available but didn't sell” — a question that can never be answered with the sales table alone.

The measure for these tables is always COUNT(*). Sometimes a constant column with the value one is added so BI tools can work with it more easily.

Compare Kimball and Inmon.

▾Intermediate
KimballInmon
DirectionBottom-upTop-down
Starting pointA data mart for one processA normalized central warehouse
ModelDimensional from the start3NF, then dimensional marts
Time to valueShortLong
RiskInconsistency between martsA long, expensive project

The practical answer: “I usually choose Kimball because it delivers value sooner, but I prevent inconsistency between marts from the start with a bus matrix and conformed dimensions.” This sentence shows you also know the weakness of your chosen approach.

What is a bus matrix?

▾Intermediate

A table that puts business processes in rows and dimensions in columns and shows which dimensions each process uses.

ProcessDateCustomerProductStoreSupplier
Sales✓✓✓✓
Inventory✓✓✓
Purchasing✓✓✓
Returns✓✓✓✓

This matrix is both the project roadmap and a guarantee that shared dimensions are built once and correctly. Drawing this very table on the whiteboard is a first-class answer to “where would you start a data warehouse”.

How do you handle late-arriving dimensions and late-arriving facts?

▾Intermediate

Late-arriving dimension: a fact row arrives but the customer isn't in the dimension table yet. The solution is to create a placeholder row (an inferred member) that is completed later.

INSERT INTO DimCustomer (CustomerID, Name, City, IsInferred, ValidFrom, ValidTo, IsCurrent)
SELECT DISTINCT s.CustomerID, 'Unknown', 'Unknown', true, CURRENT_DATE, '9999-12-31', true
FROM stg_sales s
LEFT JOIN DimCustomer d ON d.CustomerID = s.CustomerID AND d.IsCurrent
WHERE d.CustomerKey IS NULL;

Late-arriving fact: a transaction with a past date arrives. You must find the dimension version matching that transaction's date, not the current version:

JOIN DimCustomer d
  ON d.CustomerID = s.CustomerID
 AND s.TxnDate BETWEEN d.ValidFrom AND d.ValidTo

Using IsCurrent = true during loading is the simplest approach but gives wrong results for late data. A range join is the correct answer.

When do you build an aggregate table, and what are the risks?

▾Intermediate
CREATE MATERIALIZED VIEW AggSalesMonthlyCategory AS
SELECT d.Year, d.Month, p.Category, s.StoreKey,
       SUM(f.NetAmount) AS NetAmount,
       SUM(f.Quantity)  AS Quantity,
       COUNT(DISTINCT f.CustomerKey) AS Buyers
FROM FactSales f
JOIN DimDate d    ON d.DateKey    = f.DateKey
JOIN DimProduct p ON p.ProductKey = f.ProductKey
JOIN DimStore s   ON s.StoreKey   = f.StoreKey
GROUP BY 1,2,3,4;
Two risks: COUNT(DISTINCT) in an aggregate table can't be re-aggregated (the sum of monthly buyer counts is not the quarter's buyer count), and if the aggregate table and the base table aren't refreshed together, two reports give two different numbers.

Rule: build an aggregate table when you've measured that the query really is slow, and always refresh it in the same transaction as the fact load or right after it.

How do indexing and partitioning in a data warehouse differ from OLTP?

▾Intermediate
  • In OLTP, many indexes on search columns; in a warehouse, fewer but targeted indexes.
  • Fact tables are usually partitioned by date so partition pruning happens and removing old data is cheap.
  • Indexes on the fact's foreign keys are useful for joining to dimensions.
  • In columnar databases, traditional indexes give way to sorting and block-level statistics.
CREATE TABLE FactSales (...) PARTITION BY RANGE (DateKey);
CREATE TABLE FactSales_2024_08 PARTITION OF FactSales
  FOR VALUES FROM (20240801) TO (20240901);
CREATE INDEX ON FactSales_2024_08 (CustomerKey);

During bulk loads, drop the indexes and rebuild them after the insert; on tens of millions of rows it makes a dramatic difference in time.

How do you store ragged and recursive hierarchies in a dimensional model?

▾Intermediate

Like an org chart whose depth differs between branches. Two common solutions:

  1. Flattening to a fixed number of levels: Level1 to Level6 columns; simple and fast, but with empty levels and a maximum depth.
  2. A hierarchy bridge table: one row for each ancestor–descendant pair, with the level distance.
BrgOrgHierarchy(AncestorKey, DescendantKey, Depth, IsLeaf)
-- Total salary of a manager's entire subtree
SELECT SUM(f.Salary)
FROM BrgOrgHierarchy b
JOIN FactPayroll f ON f.EmployeeKey = b.DescendantKey
WHERE b.AncestorKey = :manager_key;

A bridge table gives full flexibility and supports unlimited depth, but joins become more expensive and BI tools cope with it less easily. For shallow, stable hierarchies, flattening is the more practical choice.

How do you handle units of measure and currencies in a data warehouse?

▾Intermediate
FactSales(
  ...,
  Amount_TXN      numeric(14,2),   -- in the transaction currency
  CurrencyKey     int,
  ExchangeRate    numeric(12,6),   -- the rate on that date
  Amount_REPORT   numeric(14,2)    -- in the reporting currency, precomputed
)
  • Store both values: the original currency for accounting accuracy, the reporting currency for aggregation.
  • Store the exchange rate as of the transaction date, not today's rate.
  • Keep the unit of quantity (each, kilogram, pack) in the product dimension and forbid summing mixed units.
Converting currency at report time with today's rate makes historical reports unstable: the same report gives a different number tomorrow. The conversion must be done and stored at load time, using the rate of that date.

What is SCD Type 6 and what is its advantage?

▾Intermediate

A combination of all three types: Type 2 historical rows, plus a column that always holds the current value.

CustomerKey | CustomerID | City_Historical | City_Current | ValidFrom  | IsCurrent
------------+------------+-----------------+--------------+------------+----------
       1001 |     C-102  | Tehran           | Karaj        | 2023-01-01 | false
       1450 |     C-102  | Karaj            | Karaj        | 2024-06-01 | true

With one model you can ask both “last year's sales by the customer's city at the time” and “last year's sales by the customer's current city” — two completely different questions, both legitimate.

The cost is that the current-value column has to be updated in all of that customer's historical rows, so loading gets heavier. For large dimensions, watch out for this cost.

Advanced level 13

What is Data Vault and when is it chosen?

▾Advanced
Hub      unique business key         HubCustomer(CustomerHK, CustomerID, LoadDate, Source)
Link     relationship between hubs   LinkSale(SaleHK, CustomerHK, ProductHK, LoadDate)
Satellite descriptive attributes and history SatCustomer(CustomerHK, LoadDate, Name, City, HashDiff)
  • All changes are recorded without deletion, so it is fully auditable.
  • New sources can be added without redesigning the existing model.
  • Each component can be loaded in parallel and independently.
The cost is a very large number of tables and JOINs. Data Vault isn't a consumption layer; you have to build a dimensional layer on top of it for reporting. If you don't say this, the interviewer will think you've only heard the name.

Choosing it is justified when sources are many and changing, there is a legal requirement for full traceability, or a large team works in parallel.

Design a data warehouse for an online store from scratch.

▾Advanced

Follow Kimball's standard four-step method and say it out loud:

  1. Choose the business process — online sales. Which decision is this supposed to support?
  2. Declare the grain — one row per line item of each order.
  3. Identify the dimensions — date, customer, product, channel, campaign, payment method, status.
  4. Identify the facts — quantity, gross amount, discount, net amount, cost, margin.
FactSalesLine(
  SalesLineKey bigserial,
  DateKey int, CustomerKey bigint, ProductKey bigint,
  ChannelKey int, PromoKey int, PaymentKey int,
  OrderNumber text,              -- degenerate
  Quantity int,
  GrossAmount numeric(14,2), DiscountAmount numeric(14,2),
  NetAmount numeric(14,2), CostAmount numeric(14,2),
  MarginAmount numeric(14,2) GENERATED ALWAYS AS (NetAmount - CostAmount) STORED
)

Be sure to add three things: the customer as SCD Type 2, a date table with fiscal (and, where relevant, Solar Hijri) calendars, and a separate fact for returns instead of inserting negative amounts — because their grain and dimensions differ.

How do you store returns and order cancellations in a dimensional model?

▾Advanced

There are three options, each with consequences:

  1. A negative row in the same fact — simple, but the grain is muddied and order counts come out wrong.
  2. A separate fact for returns — the cleanest option; conformed dimensions stay shared and you can drill across.
  3. Updating the original row — history is lost and historical reports change.
SELECT d.MonthName,
       SUM(s.NetAmount)                      AS gross_sales,
       COALESCE(SUM(r.RefundAmount), 0)      AS refunds,
       SUM(s.NetAmount) - COALESCE(SUM(r.RefundAmount), 0) AS net_sales
FROM DimDate d
LEFT JOIN FactSalesLine s ON s.DateKey = d.DateKey
LEFT JOIN FactReturn    r ON r.DateKey = d.DateKey
GROUP BY d.MonthName;

The key question to ask the stakeholder: should a return be attributed to the original sale date or to the return date? Both are valid, and the answer determines the report's structure.

What is a semantic layer and why is it needed on top of a data warehouse?

▾Advanced

A layer that holds the definitions of metrics and relationships once, centrally, so every tool gives the same number. Examples: the semantic model in Power BI, dbt models with metrics, or Cube and LookML.

  • The definition of “net revenue” is written once, not in seven dashboards.
  • A change to a definition is applied and versioned in one place.
  • End users work with business names, not raw table names.
  • Access control and row-level security are applied right there.

The sign that a semantic layer is missing is a meeting where two managers arrive with two different numbers for the same metric. If you've had this experience, describe it in the interview.

How do you decide whether to denormalize data in the warehouse?

▾Advanced

The decision criteria:

  • Query pattern — if a column is almost always read together with the main dimension, flattening is worth it.
  • Cardinality — a very high-cardinality text column in a large table blows up the size.
  • Rate of change — a frequently changing attribute in a large table means heavy updates.
  • Engine — in a columnar database, redundancy costs little thanks to compression; in a row-based database it is more expensive.

A good concluding line: “Denormalization is an engineering decision, not a principle. I decide based on actual query patterns and measurement, not a general rule.”

How do you handle a very large dimension with millions of rows?

▾Advanced

Like the customer dimension at a bank, with tens of millions of rows and SCD Type 2 multiplying it several times over.

  • Mini-dimension — move the frequently changing attributes (income band, credit score) into a small separate dimension and put its key in the fact.
  • Split Type 1 and Type 2 — make attributes whose history doesn't matter Type 1 so they don't generate new rows.
  • Outrigger — move shared attributes to a helper table.
  • Partitioning and compression on the dimension itself.

The mini-dimension is the main technique and is not widely known: instead of every small change creating a full new version of the customer, only the mini-dimension key changes in new fact rows.

How is referential integrity guaranteed in a data warehouse?

▾Advanced

Many warehouses don't define physical foreign keys, because they slow down bulk loading. Instead:

-- Check for orphan keys after every load
SELECT 'CustomerKey' AS col, COUNT(*) AS orphans
FROM FactSales f LEFT JOIN DimCustomer d USING (CustomerKey)
WHERE d.CustomerKey IS NULL
UNION ALL
SELECT 'ProductKey', COUNT(*)
FROM FactSales f LEFT JOIN DimProduct p USING (ProductKey)
WHERE p.ProductKey IS NULL;
col | orphans --------------+-------- CustomerKey | 0 ProductKey | 14

A common pattern: create an “Unknown” row with a zero or negative key in each dimension and assign unmatched keys to it. That way the fact row isn't dropped, the grand total stays correct, and the problem is visible in reports instead of staying hidden.

How do you test a dimensional model?

▾Advanced
  1. Key uniqueness in each dimension, and for SCD Type 2, uniqueness of the natural key plus validity period.
  2. No overlapping periods among an entity's history rows.
  3. Exactly one current row for each natural key.
  4. No orphan keys in the fact.
  5. Control totals reconciled with the source system for a few sample days.
  6. Stable grain: the number of fact rows matches the number of source records.
-- Overlapping periods in SCD2
SELECT CustomerID, COUNT(*) AS bad
FROM (
  SELECT CustomerID, ValidFrom, ValidTo,
         LEAD(ValidFrom) OVER (PARTITION BY CustomerID ORDER BY ValidFrom) AS next_from
  FROM DimCustomer
) t
WHERE next_from IS NOT NULL AND next_from <= ValidTo
GROUP BY CustomerID;

Run these tests automatically after every load. If you work with dbt, most of them exist as ready-made tests and you write the rest as custom SQL tests.

What design differences does a data warehouse on a cloud columnar database bring?

▾Advanced
  • Storage is cheap, so flattening dimensions and keeping more detail become more justified.
  • There are no traditional indexes; clustering keys, sort order and partitioning matter instead.
  • Separating compute and storage means different workloads can get separate resources.
  • Cost is based on the data scanned, so partition pruning and column selection directly affect the bill.
  • Joins are still expensive; the star schema is still the best structure.

A common mistake: assuming modeling stops mattering with a columnar database. A bad model in the cloud doesn't stop failing; it just fails more expensively.

In what cases does a data warehouse project fail?

▾Advanced
  • Starting without a clear business question; building a warehouse for the sake of “having a warehouse”.
  • No data owner and nobody to approve metric definitions.
  • Too large a scope in the first phase; eighteen months without a usable output.
  • Ignoring source data quality and hoping it gets fixed in transformation.
  • No testing or monitoring, which destroys trust with the first wrong number.
  • Designing without involving end users, which leads to a model nobody understands.

If you have real experience, tell one of these as a story. An honest answer about a failure builds more trust than a list of successes.

How do you report facts with different grains together?

▾Advanced

Like comparing sales (grain: invoice line) with a budget (grain: month and category). A direct join isn't possible and causes double counting.

-- Aggregate each fact separately to the shared level, then put them side by side
WITH s AS (
  SELECT d.Year, d.Month, p.Category, SUM(f.NetAmount) AS actual
  FROM FactSales f JOIN DimDate d USING (DateKey) JOIN DimProduct p USING (ProductKey)
  GROUP BY 1,2,3
), b AS (
  SELECT Year, Month, Category, SUM(BudgetAmount) AS budget
  FROM FactBudget GROUP BY 1,2,3
)
SELECT COALESCE(s.Year,b.Year) AS y, COALESCE(s.Month,b.Month) AS m,
       COALESCE(s.Category,b.Category) AS cat,
       COALESCE(actual,0) AS actual, COALESCE(budget,0) AS budget,
       ROUND(100.0*COALESCE(actual,0)/NULLIF(budget,0),1) AS achieved_pct
FROM s FULL JOIN b USING (Year, Month, Category);

Rule: never JOIN two facts with different grains directly. Aggregate each one separately to the “lowest common grain”, then combine them. In Power BI the same thing is done with conformed dimensions and separate measures.

How do you prepare a data warehouse for deleting one person's data (the right to be forgotten)?

▾Advanced
  1. Identify: using data lineage, find every table and file that contains the person's identifier — including staging, backups and exports.
  2. Isolate: from the start, keep identifying information in a separate dimension so a deletion touches only one place.
  3. Anonymize instead of delete: keep the fact row but move its key to a “deleted” row; that way historical statistics don't break.
  4. Record: document the date and scope of fulfilling the request.
  5. Backups: define a retention policy, because deleting from old backups usually isn't practical.

The third point is the most important design decision: if you delete fact rows, last year's revenue changes and historical financial reports become invalid. Anonymization satisfies both needs.

How do you document a data warehouse?

▾Advanced
  • Data dictionary: for every table and column, a description, type, allowed values and an example.
  • Source-to-target mapping: where each column comes from and what transformation was applied to it.
  • Metric definitions: the exact formula, filters, owner and date of last change.
  • Model diagram: the star schema with each fact's grain written on it.
  • Architecture decisions: which options were rejected and why.
  • Operations runbook: schedules, dependencies and what to do on failure.

The best documentation lives next to the code and changes with it. If you work with dbt, descriptions sit in YAML files next to the models and the docs and lineage are generated automatically — separate docs in a wiki always fall behind the code.

ETL and data pipelines

This section is about building reliable data flows: incremental loading, idempotency, change detection, error handling, reprocessing and monitoring. The questions are mostly scenario-based, and what matters is the order and logic of your decisions, not tool names.

ETL review sheet

Concepts that form the backbone of every pipelineSQL
ETL vs ELT
Transforming before loading versus transforming inside the target
Full Load
Full load; simple but expensive
Incremental Load
Only the changes since the last run
Watermark
The highest value of the change column in the previous run
CDC
Reading changes from the database's transaction log
Idempotent
Re-running doesn't change the result
Backfill
Running the pipeline for past dates
Upsert / MERGE
Insert or update based on a unique key
Dead Letter Queue
Where faulty records go for later review
Exponential Backoff
Retry with exponential backoff
Schema Drift
The source's structure changing without notice
Reconciliation
Reconciling control totals with the source system
Data Contract
The producer's written commitment to structure and quality
SLA / Freshness
A time commitment for delivery and data freshness
Lineage
Lineage: where this column came from
Orchestration
Scheduling, dependencies, retries, alerts

Basic level 9

How do ETL and ELT differ, and which do you choose?

▾Basic
ETLELT
Where transformation happensAn intermediate serverInside the target warehouse
Raw dataUsually not keptStays in the target
ReprocessingRequires reading the source againFrom the existing raw data
ToolsDedicated ETL toolsSQL and dbt
Best forLimited resources, a weak targetScalable cloud warehouses

Today's answer: “ELT, because cloud warehouses have the compute power, the raw data stays available for reprocessing, and transforming with SQL is more maintainable and testable for the team.”

What steps does a simple pipeline have?

▾Basic
Extract  →  Land (raw)  →  Validate  →  Transform  →  Load  →  Verify  →  Notify
  1. Extract — reading from the source with a defined scope (a time range or a watermark).
  2. Land — writing raw, immutable data for later reprocessing.
  3. Validate — checking the structure, row counts and critical values.
  4. Transform — cleaning, standardizing, joining and modeling.
  5. Load — an idempotent write to the target.
  6. Verify — reconciling control totals and checking quality.
  7. Notify — logs, run metrics and an alert if there's a problem.

Most candidates skip the second and sixth steps. If you mention them, it's immediately clear you've maintained a real pipeline.

Compare full and incremental loading.

▾Basic
  • Full: you read the whole source and rebuild the target. Simple and always consistent, but expensive, slow and impractical on large tables.
  • Incremental: only the changed records. Fast and cheap, but it needs a change-detection mechanism and a way to handle deletes.
-- Incremental with a watermark
SELECT * FROM source_orders
WHERE updated_at > :last_watermark
  AND updated_at <= :run_started_at;   -- upper bound to avoid skipping records
The upper bound is vital. If you only use the condition “greater than the previous watermark”, records written during the run may be seen once while the watermark moves ahead, and other concurrent records drop out forever.

What does idempotent mean, and why does it matter?

▾Basic

It means running the same job twice or three times for the same range gives the same result and doesn't double the data.

-- The drop-partition-and-reinsert pattern
BEGIN;
DELETE FROM fact_sales WHERE date_key = :run_date;
INSERT INTO fact_sales SELECT * FROM stg_sales WHERE date_key = :run_date;
COMMIT;

-- Or an upsert on a unique key
INSERT INTO dim_customer AS d (...)
VALUES (...)
ON CONFLICT (customer_id) DO UPDATE SET ...;

Every pipeline gets re-run sooner or later: a network error, an orchestrator retry, or a manual backfill. If it isn't idempotent, the data gets corrupted every time and nobody trusts the reports.

The nightly pipeline has failed. What are your first steps?

▾Basic
  1. Read the run log and find the exact error message and the step that failed.
  2. See whether the failure was complete or partial; if partial, check the state of the target.
  3. If it's a transient error (network, lock, rate limit), re-run it.
  4. If it's a data error, take a sample of the faulty records and find the cause.
  5. Let the consumers know today's report is delayed.
  6. After the fix, run the quality checks and make sure the result is correct.

Don't forget the fifth point. Silence during an outage hurts trust more than the outage itself.

What should logging in a pipeline look like?

▾Basic
import logging, json, time
log = logging.getLogger("etl.orders")

t0 = time.time()
log.info(json.dumps({"event":"start","task":"load_orders","run_date":str(run_date)}, ensure_ascii=False))
rows = load(run_date)
log.info(json.dumps({"event":"finish","task":"load_orders","run_date":str(run_date),
                     "rows":rows,"seconds":round(time.time()-t0,1)}, ensure_ascii=False))
{"event":"start","task":"load_orders","run_date":"2024-08-31"} {"event":"finish","task":"load_orders","run_date":"2024-08-31","rows":18420,"seconds":42.7}

Structured logs (JSON) instead of free text make it possible to search and chart row counts and run times. Always log three things: the run parameters, the row count and the duration.

Where do you keep passwords and credentials?

▾Basic
import os
from sqlalchemy import create_engine

dsn = os.environ["WAREHOUSE_DSN"]     # from an environment variable, not from code
engine = create_engine(dsn)
  • Never in code or in Git; the .env file must be in .gitignore.
  • In production, use a secrets manager or the orchestrator's managed connections.
  • The database user should have the minimum access it needs.
  • Never print the full connection string in logs.

If a secret is committed by mistake, deleting the file isn't enough; that credential is burned and must be revoked and reissued immediately.

How do you decide the batch window?

▾Basic
  • When the source data closes: until what time are yesterday's records still being written?
  • The time needed for the run, plus a safety margin for retries.
  • The time the consumer wants the report.
  • Load on the source system; choose low-traffic hours.
  • Time zones; in a multinational organization, “yesterday” has no single definition.

Practical rule: arrange the window so at least two retries fit before the delivery time. A pipeline that takes right up to the last minute breaks the SLA at the first network slowdown.

What is a staging area, and why don't we load data straight into the target?

▾Basic
  • The source is released quickly; no long connection to the operational system stays open.
  • If the transformation fails, you don't need to read the source again.
  • You can compare and verify the raw data against the result.
  • Bulk loading happens without locking the target tables.
TRUNCATE stg_orders;
COPY stg_orders FROM '/data/orders_2024-08-31.csv' WITH CSV HEADER;
-- Then transform and insert into the final table from this staging table

Always empty the staging table before each run, not after it. If the previous run was left half-finished, old content gets mixed with new data and that is hard to detect.

Intermediate level 12

Compare change detection methods.

▾Intermediate
MethodAdvantageLimitation
An updated_at columnSimple, no special access neededDoesn't see deletes; depends on the column being correct
Increasing idSimple and fastOnly new inserts, not updates
Comparing a hash of the whole rowCatches any changeRequires reading the whole source
CDC from the transaction logEvery operation including deletes, low loadSetup and database-level access
Triggers in the sourcePreciseLoad on the operational system

Always raise the problem of deletes: with updated_at, a record deleted in the source stays in the warehouse. Solutions: soft deletes in the source, periodically comparing the list of keys, or moving to CDC.

What is CDC and how do you consume it?

▾Intermediate
Postgres WAL ──► Debezium ──► Kafka topic ──► consumer ──► Delta/warehouse
Sample message: {"op":"u","before":{...},"after":{...},"ts_ms":1725...}
  • Inserts, updates and deletes are all captured.
  • No load is placed on the operational system's queries, because it reads from the log.
  • Very low latency and the ability to replay from an earlier offset.
-- Getting the latest state of each record from the CDC stream
SELECT DISTINCT ON (id) *
FROM cdc_orders
WHERE op <> 'd'
ORDER BY id, ts_ms DESC;

An important point: a CDC stream is ordered per key, not globally. So always pick the latest version by key and timestamp, and don't assume messages arrive in order.

How do you design a backfill?

▾Intermediate
def run(run_date):
    """All logic is driven by the date parameter, not by today."""
    raw   = extract(run_date)
    clean = transform(raw)
    load(clean, partition=run_date)     # drop the partition and re-insert

# Re-run for a past month
for d in pd.date_range("2024-07-01", "2024-07-31"):
    run(d.date())
  • Nowhere in the code should today() be called; everything comes from the date parameter.
  • Each partition should be independent so they can run in parallel.
  • A backfill must not run at the same time as the regular run on the same partition.
  • Respect the source's rate limits; a one-year backfill can bring the source system down.

The key sentence: “I write the pipeline so that today's run and a run for six months ago have no difference in code; the only difference is the parameter value.”

How do you categorize and handle errors in a pipeline?

▾Intermediate
Error typeExampleResponse
TransientNetwork outage, rate limitRetry with exponential backoff
DataInvalid date, missing keyDivert the record to an error queue, keep going
StructuralA column was removedStop completely and alert
LogicalTotals don't match the sourceDon't publish; review manually
good = df[df["amount"].notna() & (df["amount"] >= 0)]
bad  = df.drop(good.index)
if len(bad):
    bad.assign(rejected_at=pd.Timestamp.utcnow(), reason="invalid_amount") \
       .to_parquet(f"s3://lake/_rejected/orders/{run_date}.parquet")
    log.warning("rejected %d rows (%.2f%%)", len(bad), 100*len(bad)/len(df))
if len(bad) / max(len(df), 1) > 0.02:
    raise ValueError("Faulty record rate exceeded the threshold")

Two key points: don't throw bad records away, set them aside so they can be reviewed; and set a threshold — a few faulty records is normal, two percent signals a structural problem.

How do you contain unexpected changes to the source structure (schema drift)?

▾Intermediate
EXPECTED = {"order_id":"int64","customer_id":"int64",
            "order_date":"datetime64[ns]","amount":"float64","status":"object"}

missing = set(EXPECTED) - set(df.columns)
if missing:
    raise ValueError(f"Columns missing in source: {missing}")

extra = set(df.columns) - set(EXPECTED)
if extra:
    log.warning("New columns ignored: %s", extra)

for c, t in EXPECTED.items():
    if str(df[c].dtype) != t:
        log.warning("Type of %s changed: %s (expected %s)", c, df[c].dtype, t)

A suggested policy: ignore new columns and just warn; treat missing columns or incompatible type changes as stopping errors.

The long-term solution is a data contract: the producer commits not to change the structure without prior notice, and incompatible changes are blocked in CI.

How do you do reconciliation?

▾Intermediate
-- Daily comparison of source and target
WITH src AS (
  SELECT order_date::date AS d, COUNT(*) AS rows, SUM(amount) AS amt
  FROM source_orders WHERE order_date::date = :run_date GROUP BY 1
), tgt AS (
  SELECT date_key AS d, COUNT(*) AS rows, SUM(net_amount) AS amt
  FROM fact_sales WHERE date_key = :run_date GROUP BY 1
)
SELECT s.d, s.rows AS src_rows, t.rows AS tgt_rows,
       s.amt AS src_amt, t.amt AS tgt_amt,
       s.rows - t.rows AS row_diff,
       ROUND(s.amt - t.amt, 2) AS amt_diff
FROM src s FULL JOIN tgt t USING (d);
d | src_rows | tgt_rows | src_amt | tgt_amt | row_diff | amt_diff ------------+----------+----------+-------------+-------------+----------+--------- 2024-08-31 | 18,420 | 18,420 | 291,004,500 | 291,004,500 | 0 | 0.00

Run this query as the last step of the pipeline, and if there's a difference, stop publishing. This is exactly the difference between a professional pipeline and an amateur one: it notices it's broken itself, instead of a manager noticing.

How do you remove duplicate records during loading?

▾Intermediate
-- Keep the latest version of each key before inserting
INSERT INTO fact_sales
SELECT * FROM (
  SELECT s.*,
         ROW_NUMBER() OVER (PARTITION BY s.order_id, s.line_no
                            ORDER BY s.updated_at DESC, s.ingest_ts DESC) AS rn
  FROM stg_sales s
) t
WHERE rn = 1
ON CONFLICT (order_id, line_no) DO UPDATE
SET net_amount = EXCLUDED.net_amount,
    updated_at = EXCLUDED.updated_at
WHERE fact_sales.updated_at < EXCLUDED.updated_at;

The final condition matters: it prevents a newer record from being overwritten by an older version. When processing out-of-order messages — which is normal in CDC and Kafka — this protection is essential.

How do you generate surrogate keys in a pipeline?

▾Intermediate
  • A database sequence — simple and compact, but a bottleneck in parallel or distributed loads.
  • A hash of the business key — can be computed in parallel with no central coordination; the common approach in Data Vault.
  • UUID — collision-free but bulky and slow for joins.
-- Hash key, stable and reproducible
SELECT md5(coalesce(source_system,'') || '|' || coalesce(customer_id,'')) AS customer_hk
FROM stg_customers;

The advantage of a hash key is that if you run the pipeline from scratch, the same keys are produced and joins don't break. With a database sequence, a re-run produces different keys and the whole warehouse has to be rebuilt.

How do you manage dependencies between tasks?

▾Intermediate
extract_orders    >> transform_orders
extract_customers >> transform_customers
[transform_orders, transform_customers] >> build_fact >> quality_checks >> publish
  • Dimensions should load before facts, otherwise orphan keys are produced.
  • Independent tasks should run in parallel to keep the batch window short.
  • Quality checks should come before publishing, not after.
  • The graph must have no cycles; the orchestrator checks this itself.
  • For a dependency on another pipeline, use a sensor or an event, not a fixed delay.

The last point matters: “wait 30 minutes, then start” is the most fragile pattern there is. If the upstream pipeline is slower one day, you silently read incomplete data.

What does testing an ETL look like?

▾Intermediate
import pandas as pd, pytest
from transform import build_fact

def test_grain_is_unique():
    src = pd.DataFrame({"order_id":[1,1,2],"line_no":[1,2,1],"qty":[1,2,3],"price":[10,10,5]})
    out = build_fact(src)
    assert not out.duplicated(subset=["order_id","line_no"]).any()

def test_amount_calculation():
    src = pd.DataFrame({"order_id":[1],"line_no":[1],"qty":[3],"price":[1000]})
    assert build_fact(src)["net_amount"].iloc[0] == 3000

@pytest.mark.parametrize("bad", [None, -5])
def test_rejects_invalid_qty(bad):
    src = pd.DataFrame({"order_id":[1],"line_no":[1],"qty":[bad],"price":[10]})
    assert len(build_fact(src)) == 0
$ pytest -q 4 passed in 0.21s

The key to testability is that transformation functions are pure: they take a DataFrame and return a DataFrame, without connecting to a database. Then tests run fast and without infrastructure.

How do you design a pipeline to run in parallel?

▾Intermediate
from concurrent.futures import ThreadPoolExecutor, as_completed

def load_partition(day):
    df = extract(day)
    load(transform(df), partition=day)
    return day, len(df)

days = pd.date_range("2024-07-01", "2024-07-31").date
with ThreadPoolExecutor(max_workers=4) as ex:
    futures = {ex.submit(load_partition, d): d for d in days}
    for f in as_completed(futures):
        try:
            d, n = f.result()
            log.info("%s → %d rows", d, n)
        except Exception as e:
            log.error("%s failed: %s", futures[f], e)

Three conditions for safe parallelism: each unit of work writes to an independent partition, the source can handle the concurrent load, and one unit's failure doesn't stop the others. Without the first condition, two workers write to the same table and you get locks and deadlocks.

How do you define an SLA and monitor data freshness?

▾Intermediate
CREATE TABLE etl_sla (
  table_name text PRIMARY KEY,
  owner      text,
  sla_hours  int,
  schedule   text
);
INSERT INTO etl_sla VALUES
 ('fact_sales',   'data-team', 6,  'daily 2 AM'),
 ('dim_customer', 'data-team', 24, 'daily 1 AM');

-- Alert
SELECT s.table_name, s.owner, f.max_loaded_at,
       ROUND(EXTRACT(EPOCH FROM (now() - f.max_loaded_at))/3600, 1) AS hours_stale
FROM etl_sla s JOIN etl_freshness f USING (table_name)
WHERE now() - f.max_loaded_at > (s.sla_hours || ' hours')::interval;

The time commitment must be agreed with the stakeholder, not announced unilaterally by the data team. The right question is: “If this table is six hours behind, which decision is harmed?” The answer is the SLA value.

Advanced level 9

How do you design pipeline monitoring?

▾Advanced

Four layers of monitoring that should all be in place together:

  1. Execution — success or failure, duration, number of retries.
  2. Volume — today's row count compared with a moving average; a sudden drop should raise an alert.
  3. Quality — rate of empty values, orphan keys, rate of rejected records.
  4. Freshness — each table's last update compared with its SLA.
-- Freshness alert
SELECT table_name,
       max_loaded_at,
       EXTRACT(EPOCH FROM (now() - max_loaded_at))/3600 AS hours_stale,
       sla_hours,
       (EXTRACT(EPOCH FROM (now() - max_loaded_at))/3600) > sla_hours AS breached
FROM etl_freshness
ORDER BY breached DESC, hours_stale DESC;
table_name | max_loaded_at | hours_stale | sla_hours | breached -------------+---------------------+-------------+-----------+--------- fact_sales | 2024-08-31 02:14:00 | 3.1 | 6 | f dim_customer | 2024-08-29 02:11:00 | 51.2 | 24 | t

The most dangerous case is a silent failure: the pipeline reports green but brought in zero rows. Volume and freshness monitoring exist precisely to catch this.

How do you bring a pipeline that takes four hours down to one hour?

▾Advanced
  1. Measure — time each step separately; usually 80 percent of the time is in one step.
  2. Reduce the data — move from full to incremental loading; this is usually the biggest win.
  3. Push processing to the source — do filtering and aggregation in the query, not in Python.
  4. Batch — replace single-row inserts with bulk inserts or COPY.
  5. Parallelize — run independent tasks at the same time.
  6. Format — move from CSV to Parquet.
  7. Indexes — drop them during bulk inserts and rebuild afterwards.
# Instead of thousands of single INSERTs
buf = io.StringIO(); df.to_csv(buf, index=False, header=False); buf.seek(0)
with engine.raw_connection().cursor() as cur:
    cur.copy_expert("COPY stg_sales FROM STDIN WITH CSV", buf)

In practice, the first two items usually deliver more than 90 percent of the improvement. If you jump straight to parallelism, you've added complexity without solving the root cause.

Two sources give two different numbers for the same metric. How do you resolve it?

▾Advanced
  1. Compare the definitions — filters, time range, included statuses, currency and tax.
  2. Check the time zone and day boundary; this is the most common cause of small discrepancies.
  3. Freshness — is one of the sources behind?
  4. Go down to record level — take one day and one segment and list the differing records.
  5. Decide the source of truth and write it down.
  6. Build an automated reconciliation test so it alerts on its own next time.
-- Finding the differing records
SELECT COALESCE(a.order_id, b.order_id) AS order_id,
       a.amount AS src_amount, b.amount AS dwh_amount
FROM source_view a
FULL JOIN dwh_view b USING (order_id)
WHERE a.order_id IS NULL OR b.order_id IS NULL
   OR a.amount IS DISTINCT FROM b.amount
LIMIT 50;

The key sentence: “I track down a discrepancy from the lowest level, not from the grand total. Comparing two big numbers never leads to the cause.”

What is a data contract and how is it enforced?

▾Advanced

A written, machine-readable agreement between the producer and consumer of data about structure, meaning, quality and timing commitments.

dataset: orders
owner: team-checkout
schema:
  order_id:   {type: int64,  required: true, unique: true}
  amount:     {type: decimal, required: true, min: 0}
  status:     {type: string, allowed: [pending, paid, shipped, cancelled]}
sla:
  freshness_hours: 6
  completeness: 0.999
change_policy: incompatible changes require 30 days' notice
  • It is checked in the producer's CI so incompatible changes are blocked.
  • Validation also happens when data comes in.
  • There is a clear owner, so when there's a problem you know whom to talk to.

This concept solves a root problem: most pipeline breakages come from unannounced changes in an upstream system, not from bugs in the data team's code.

What is data lineage and when does it save you?

▾Advanced

A map showing where each table and column came from and what sits downstream of it.

  • Impact analysis — knowing which dashboards will break before you change a column.
  • Root-cause analysis — when a number is wrong, tracing the path back to the source.
  • Compliance — showing where sensitive data has gone.
  • Data deletion — applying a request to delete a person's data across every path.

If you work with dbt, lineage is generated automatically from ref. Data catalog tools build the same thing organization-wide, including dashboards.

How do you design a pipeline for fault tolerance and recovery?

▾Advanced
  • Checkpoints — each step records its state so a re-run continues from there.
  • Transactions or atomic writes — consumers never see a half-finished state.
  • Retries only for transient errors, with exponential backoff and a clear limit.
  • Isolate partitions — one day's failure shouldn't break the other days.
  • A timeout on every external call so work never hangs forever.
  • Alert a human when automatic retries don't work.
BEGIN;
  CREATE TEMP TABLE new_data AS SELECT ...;
  DELETE FROM fact_sales WHERE date_key = :run_date;
  INSERT INTO fact_sales SELECT * FROM new_data;
COMMIT;   -- or nothing: consumers never see the intermediate state

The “build it fully on the side, then swap atomically” pattern is the best defense: build the new table, validate it, and finally replace the old one by renaming or swapping the partition.

Off-the-shelf ETL tool or custom code? How do you decide?

▾Advanced
OptionBest forHidden cost
Managed tools (Fivetran and similar)Standard, common sourcesVolume-based pricing, little flexibility
Open-source tools (Airbyte, Meltano)More control at lower costInfrastructure maintenance
Custom codeComplex logic or a non-standard sourceDevelopment and maintenance time
dbt for transformationWherever the target speaks SQLLimited to the transformation layer

The common pattern today is a mix: ingestion with an off-the-shelf tool, transformation with dbt, and custom code only for particular sources that have no connector. If you explain this combination, you show you know today's ecosystem.

How do you design a pipeline for sensitive data?

▾Advanced
  • Minimize — don't extract a column you don't need at all.
  • Mask or hash personal identifiers right at the ingestion layer.
  • Isolate — sensitive data in a separate schema with restricted access.
  • Encrypt in transit and at rest.
  • Log access and review permissions periodically.
  • Retention policy and automatic deletion after the permitted period.
CREATE VIEW analytics.customers_masked AS
SELECT customer_key,
       md5(national_id)                       AS national_id_hash,
       left(email, 2) || '***@' || split_part(email, '@', 2) AS email_masked,
       city, segment, signup_date
FROM raw.customers;

An important point that is rarely mentioned: hashing alone isn't enough for identifiers with a small value space (such as national ID numbers), because they can be guessed. A salt must be added, and the salt itself must be protected.

How do you handle a pipeline whose source sometimes arrives late?

▾Advanced

Three common patterns, in order of preference:

  1. Sensor: the pipeline waits for the file or a readiness signal from the source to arrive, with a set timeout. The best option, because it doesn't depend on a fixed schedule.
  2. Event-driven runs: the source announces it is ready itself, with a message or a webhook call.
  3. Rolling window: each run reprocesses the last two or three days as well as today, so late records aren't missed.
wait = FileSensor(task_id="wait_for_file", filepath="/data/orders_{{ ds }}.csv",
                  poke_interval=300, timeout=6*3600, mode="reschedule")
wait >> extract >> transform >> load

The reschedule mode matters: instead of occupying a worker for the whole wait, the task is released and checks again periodically. With long waits and several concurrent pipelines, this is the difference between a healthy cluster and a locked-up one.

Key performance indicators (KPIs)

Knowing the tools without knowing what to measure is only half the job. This section covers the core business metrics, their formulas, interpretation traps and how to implement them; the topic with the most day-to-day use in data analyst and product analyst roles.

Metrics review sheet

The most-used formulas at a glanceFORMULAS
GMV = SUM(order_amount)
Gross merchandise value, before discounts and returns are deducted
Net Revenue = GMV - discounts - returns - cancellations
Net revenue; the number that actually reaches the business
AOV = Revenue / Orders
Average value of each order
ARPU = Revenue / Active Users
Revenue per active user
Conversion = Buyers / Visitors
Conversion rate; computed separately at each funnel step
Retention(n) = Active in period n / Cohort size
Cohort retention rate in period n
Churn = 1 - Retention
Churn rate over the same period
LTV = ARPU * Gross Margin / Churn Rate
Customer lifetime value; the simple contractual version
CAC = Sales+Marketing Spend / New Customers
Cost of acquiring each new customer
LTV : CAC (target ≥ 3)
Health of unit economics; below 1 means every customer loses money
Payback = CAC / (ARPU * Margin)
How many months it takes to recover the acquisition cost
MRR = SUM(monthly subscription)
Monthly recurring revenue; the basis of a subscription business
NRR = (Start + Expansion - Churn - Contraction) / Start
Net revenue retention; above 100% means growth without acquisition
Stickiness = DAU / MAU
Product stickiness; 20% is average, above 50% is excellent
ROAS = Revenue / Ad Spend
Return on ad spend
NPS = %Promoters - %Detractors
Net Promoter Score, from minus 100 to 100
CAGR = (End/Start)^(1/n) - 1
Compound annual growth rate
Contribution Margin = Revenue - Variable Cost
Contribution margin; the basis for pricing decisions

Basic level 10

What is a KPI, and how does it differ from an ordinary reported number?

▾Basic

A KPI is a metric tied directly to a business goal, whose change should lead to a decision. Not every number you can measure is a KPI.

A good KPI has five qualities:

  • Actionable: if it gets worse, you know what to do.
  • Uniquely defined: everyone in the organization understands it the same way.
  • Repeatedly measurable: you can see its trend, not just a snapshot.
  • Resistant to gaming: it can't be improved through tricks without creating value.
  • Owned: one person or team is responsible for it.

An example of the difference: “number of page views” is a reported number; “the conversion rate from views to orders” is a KPI, because a drop leads directly to investigating the product page or the checkout process.

Explain the difference between leading and lagging metrics with examples.

▾Basic
LeadingLagging
NaturePredicts the futureReports the past
Ability to influenceHigh, right nowLow, it has already happened
ExampleNumber of new leads, activation rate, support response timeQuarterly revenue, churn rate, net profit

In practice you need both: a lagging metric tells you where you are, a leading metric tells you where you're likely to be.

In a dashboard, put the lagging metrics at the top in large type, but under each one show the related leading metrics — that is what turns a dashboard from a report into a management tool.

What does vanity metric mean?

▾Basic

A number that is always big and growing but doesn't change any decision.

Vanity metricMeaningful alternative
Total number of registered usersMonthly active users
Number of app downloadsActivation rate and day-7 retention
Number of page viewsConversion rate at each funnel step
Number of followersReferral traffic and attributed revenue

A simple test: ask yourself “if this number fell 20 percent, what would I do differently tomorrow?” If you have no answer, it's a vanity metric.

Compute AOV and ARPU and explain how they differ.

▾Basic
SELECT DATE_TRUNC('month', o.order_date)::date       AS month,
       SUM(o.amount)                                  AS revenue,
       COUNT(*)                                       AS orders,
       COUNT(DISTINCT o.customer_id)                  AS buyers,
       ROUND(SUM(o.amount) / COUNT(*))                AS aov,
       ROUND(SUM(o.amount) / COUNT(DISTINCT o.customer_id)) AS arpu
FROM orders o
WHERE o.status = 'paid'
GROUP BY 1
ORDER BY 1;
month | revenue | orders | buyers | aov | arpu -----------+-------------+--------+--------+---------+-------- 2024-06-01 | 180,000,000 | 1,120 | 890 | 160,714 | 202,247 2024-07-01 | 225,000,000 | 1,310 | 970 | 171,756 | 231,959

AOV has the number of orders as its denominator and says how big each purchase is. ARPU has the number of users as its denominator, so it also captures the effect of “how many times they buy”.

If ARPU grows but AOV stays flat, customers are buying more often rather than building bigger baskets. This distinction directly affects whether the team works on cross-selling or on bringing customers back.

How do you define and compute a conversion rate?

▾Basic

The conversion rate is the share of those who completed a step out of those who entered it. Three things must be clear before computing it: the counting unit (user or session), the time range, and whether counts are distinct.

SELECT COUNT(DISTINCT user_id) FILTER (WHERE step = 'visit')    AS visitors,
       COUNT(DISTINCT user_id) FILTER (WHERE step = 'purchase') AS buyers,
       ROUND(100.0 * COUNT(DISTINCT user_id) FILTER (WHERE step = 'purchase')
                   / NULLIF(COUNT(DISTINCT user_id) FILTER (WHERE step = 'visit'), 0), 2) AS cvr_pct
FROM funnel_events
WHERE event_date >= CURRENT_DATE - 30;
visitors | buyers | cvr_pct ---------+--------+-------- 52,000 | 3,100 | 5.96
Session-based and user-based conversion give two completely different numbers. If one user visits three times and buys once, user conversion is 100 percent and session conversion about 33 percent. Agree on the unit with the stakeholder before reporting.

What do DAU, WAU and MAU tell you, and what is the stickiness ratio?

▾Basic
SELECT d.day,
       COUNT(DISTINCT e.user_id) FILTER (WHERE e.day = d.day)                        AS dau,
       COUNT(DISTINCT e.user_id) FILTER (WHERE e.day >  d.day - 30)                  AS mau,
       ROUND(100.0 * COUNT(DISTINCT e.user_id) FILTER (WHERE e.day = d.day)
             / NULLIF(COUNT(DISTINCT e.user_id) FILTER (WHERE e.day > d.day - 30), 0), 1) AS stickiness
FROM calendar d
JOIN daily_active e ON e.day > d.day - 30 AND e.day <= d.day
GROUP BY d.day ORDER BY d.day DESC LIMIT 3;
day | dau | mau | stickiness -----------+--------+---------+---------- 2024-08-31 | 18,400 | 74,200 | 24.8 2024-08-30 | 19,100 | 74,050 | 25.8

Stickiness means, on average, how many of the 30 days each monthly user was active. A value of 25 percent means about 7 to 8 days a month.

This ratio only makes sense for products expected to be used daily. For an annual tax-filing system, low stickiness is entirely natural and tracking it is misleading.

Explain gross margin and contribution margin.

▾Basic
Gross Margin      = (Revenue - COGS) / Revenue
Contribution Margin = Revenue - Variable Costs
Operating Margin  = (Revenue - COGS - OpEx) / Revenue
  • COGS is the cost of goods sold: the product itself, packaging, shipping.
  • Variable costs include, on top of COGS, payment gateway fees, direct advertising costs and variable support.
Revenue 225,000,000 COGS -142,000,000 Gross Margin 83,000,000 (36.9%) Variable costs -21,000,000 Contribution 62,000,000 (27.6%)

A common mistake of junior analysts is to treat revenue growth as success. If a campaign raises revenue 30 percent but makes the contribution margin negative, that growth is actually a loss.

Explain the difference between the mean, median and mode in reporting.

▾Basic
SELECT ROUND(AVG(amount))                                       AS mean,
       PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount)      AS median,
       MODE() WITHIN GROUP (ORDER BY amount)                    AS mode,
       PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY amount)      AS p90
FROM orders WHERE status='paid';
mean | median | mode | p90 ---------+---------+--------+---------- 186,420 | 142,000 | 99,000 | 420,000

A large gap between the mean and median means the distribution is skewed and a few large orders have pulled the mean up. In management reports, the median is usually more honest than the mean for amounts and durations — and if you show both side by side, the gap itself is an analytical finding.

What are the basic retail and e-commerce metrics?

▾Basic
MetricFormulaTells you
Cart abandonment rate1 − purchases ÷ add-to-cartsProblems in the checkout process
Return rateReturns ÷ salesProduct quality or inaccurate descriptions
Items per orderNumber of items ÷ ordersThe effect of cross-selling
Repeat purchase rateCustomers with 2+ purchases ÷ totalLoyalty
Share of stock near expiryValue of aging stock ÷ total inventoryWorking capital risk
On-time deliveryOrders within the deadline ÷ totalOperational health

Cart abandonment is usually between 60 and 80 percent, and a high number isn't necessarily a crisis. What matters is its trend and how it compares across devices and channels, not its absolute value.

Compute a percentage change correctly and describe its traps.

▾Basic
-- Percentage change
(new - old) / NULLIF(old, 0)

-- Percentage-point difference: when the metric itself is a percentage
new_rate - old_rate     -- “3 percentage points”, not “3 percent”
Conversion rate: from 4% to 6% Relative change = +50% Point difference = +2 percentage points
Three common traps: confusing “percent” and “percentage points”; computing growth when the base value is zero or negative, which gives a meaningless number; and averaging percentages instead of computing the percentage of the grand total.

Intermediate level 11

Build and interpret cohort retention.

▾Intermediate
WITH first_order AS (
  SELECT customer_id, DATE_TRUNC('month', MIN(order_date)) AS cohort
  FROM orders WHERE status = 'paid' GROUP BY customer_id
), activity AS (
  SELECT f.cohort,
         f.customer_id,
         (EXTRACT(YEAR  FROM o.order_date) - EXTRACT(YEAR  FROM f.cohort)) * 12
       + (EXTRACT(MONTH FROM o.order_date) - EXTRACT(MONTH FROM f.cohort)) AS month_no
  FROM orders o JOIN first_order f USING (customer_id)
  WHERE o.status = 'paid'
  GROUP BY 1, 2, 3
)
SELECT cohort,
       COUNT(DISTINCT customer_id) FILTER (WHERE month_no = 0) AS m0,
       ROUND(100.0 * COUNT(DISTINCT customer_id) FILTER (WHERE month_no = 1)
             / NULLIF(COUNT(DISTINCT customer_id) FILTER (WHERE month_no = 0),0), 1) AS m1_pct,
       ROUND(100.0 * COUNT(DISTINCT customer_id) FILTER (WHERE month_no = 3)
             / NULLIF(COUNT(DISTINCT customer_id) FILTER (WHERE month_no = 0),0), 1) AS m3_pct
FROM activity GROUP BY cohort ORDER BY cohort;
cohort | m0 | m1_pct | m3_pct -----------+-------+--------+------- 2024-04-01 | 1,200 | 32.0 | 19.4 2024-05-01 | 1,450 | 35.1 | 22.8 2024-06-01 | 1,610 | 38.7 | 26.1

You read three things in a cohort table: the first-step drop (how many users leave immediately), the slope of the curve in later months (does it flatten or go to zero), and a vertical comparison of cohorts (has the product improved over time).

In the output above, each new cohort is better than the previous one — a good sign of improving user experience or acquisition quality. If the curve flattens by the third month, you've found a loyal core; if it keeps falling, the product hasn't found product–market fit yet.

How do you define churn? What about in a non-subscription business?

▾Intermediate

In a subscription business it's simple: the customer didn't renew. In e-commerce there's no contractual definition, so one has to be built.

-- Churn based on each customer's actual purchase cycle
WITH gaps AS (
  SELECT customer_id,
         order_date - LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) AS gap
  FROM orders WHERE status = 'paid'
), profile AS (
  SELECT customer_id,
         PERCENTILE_CONT(0.8) WITHIN GROUP (ORDER BY gap) AS p80_gap
  FROM gaps WHERE gap IS NOT NULL GROUP BY customer_id
)
SELECT o.customer_id,
       MAX(o.order_date)                       AS last_order,
       CURRENT_DATE - MAX(o.order_date)        AS days_idle,
       p.p80_gap,
       (CURRENT_DATE - MAX(o.order_date)) > COALESCE(p.p80_gap, 90) AS at_risk
FROM orders o LEFT JOIN profile p USING (customer_id)
WHERE o.status = 'paid'
GROUP BY o.customer_id, p.p80_gap;
customer_id | last_order | days_idle | p80_gap | at_risk ------------+------------+-----------+---------+-------- 118 | 2024-01-04 | 240 | 62 | t 102 | 2024-08-19 | 12 | 41 | f

A fixed threshold for everyone (say 90 days) wrongly marks as churned someone who makes a big purchase every six months. A personalized threshold based on the customer's own behavior noticeably improves campaign targeting.

How do you compute customer lifetime value (LTV)?

▾Intermediate

There are three levels of precision:

  1. Historical: the total gross margin the customer has generated to date. Accurate but backward-looking.
  2. Simple and forward-looking: ARPU × Gross Margin ÷ Churn Rate.
  3. Probabilistic: models such as BG/NBD and Gamma-Gamma, which model the number of future purchases and their value separately.
SELECT customer_id,
       SUM(amount)                                        AS lifetime_revenue,
       SUM(amount) * 0.37                                 AS lifetime_margin,
       COUNT(*)                                           AS orders,
       MIN(order_date)                                    AS first_order,
       ROUND(SUM(amount) / COUNT(*))                      AS aov
FROM orders WHERE status = 'paid'
GROUP BY customer_id
ORDER BY lifetime_margin DESC
LIMIT 5;
customer_id | lifetime_revenue | lifetime_margin | orders | aov ------------+------------------+-----------------+--------+-------- 102 | 12,400,000 | 4,588,000 | 9 | 1,377,778 118 | 9,800,000 | 3,626,000 | 4 | 2,450,000
Two common mistakes: using revenue instead of margin (which overestimates LTV several times over), and ignoring discounting over multi-year horizons. If you take the churn rate from the overall average, you overestimate for new customers and underestimate for loyal ones.

How do you interpret CAC and the LTV to CAC ratio?

▾Intermediate
WITH spend AS (
  SELECT DATE_TRUNC('month', spend_date) AS m, channel, SUM(cost) AS cost
  FROM marketing_spend GROUP BY 1, 2
), acq AS (
  SELECT DATE_TRUNC('month', first_order_date) AS m, channel,
         COUNT(*) AS new_customers,
         SUM(first_year_margin) AS margin
  FROM customer_acquisition GROUP BY 1, 2
)
SELECT s.m, s.channel,
       s.cost, a.new_customers,
       ROUND(s.cost / NULLIF(a.new_customers, 0))                AS cac,
       ROUND(a.margin / NULLIF(a.new_customers, 0))              AS ltv_1y,
       ROUND((a.margin / NULLIF(a.new_customers,0)) /
             NULLIF(s.cost / NULLIF(a.new_customers,0), 0), 2)   AS ltv_cac
FROM spend s JOIN acq a USING (m, channel)
ORDER BY ltv_cac DESC;
m | channel | cost | new | cac | ltv_1y | ltv_cac -----------+----------+------------+-----+---------+---------+-------- 2024-07-01 | organic | 12,000,000 | 410 | 29,268 | 168,000 | 5.74 2024-07-01 | search | 88,000,000 | 520 | 169,231 | 210,000 | 1.24 2024-07-01 | display | 64,000,000 | 130 | 492,308 | 145,000 | 0.29

Rule of thumb: a ratio below 1 means losses, around 3 is healthy, and above 5 may signal under-investment in growth.

In the table above the display channel is clearly unprofitable, but before cutting its budget you should check whether it has an indirect effect — last-click attribution systematically undervalues awareness channels.

Build a multi-step conversion funnel that preserves time order.

▾Intermediate
WITH steps AS (
  SELECT user_id,
         MIN(event_time) FILTER (WHERE event_name = 'view')        AS t_view,
         MIN(event_time) FILTER (WHERE event_name = 'add_to_cart') AS t_cart,
         MIN(event_time) FILTER (WHERE event_name = 'checkout')    AS t_check,
         MIN(event_time) FILTER (WHERE event_name = 'purchase')    AS t_buy
  FROM events
  WHERE event_time >= CURRENT_DATE - 30
  GROUP BY user_id
)
SELECT COUNT(*) FILTER (WHERE t_view IS NOT NULL)                     AS s1_view,
       COUNT(*) FILTER (WHERE t_cart  > t_view)                        AS s2_cart,
       COUNT(*) FILTER (WHERE t_check > t_cart)                        AS s3_checkout,
       COUNT(*) FILTER (WHERE t_buy   > t_check)                       AS s4_purchase
FROM steps;
s1_view | s2_cart | s3_checkout | s4_purchase --------+---------+-------------+------------ 52,000 | 11,400 | 5,900 | 3,100

The drops: 78 percent from view to cart, 48 percent from cart to checkout, 47 percent from checkout to final purchase.

Using the condition t_cart > t_view instead of merely “the event exists” makes an important difference: the ordered version avoids counting users who went straight into the cart from a campaign link. Which definition is right depends on the business question, and you should state it explicitly.

Break MRR and the components of its change apart.

▾Intermediate
WITH m AS (
  SELECT customer_id, month, mrr,
         LAG(mrr) OVER (PARTITION BY customer_id ORDER BY month) AS prev_mrr
  FROM subscription_monthly
)
SELECT month,
       SUM(mrr)                                                      AS total_mrr,
       SUM(CASE WHEN prev_mrr IS NULL           THEN mrr ELSE 0 END) AS new_mrr,
       SUM(CASE WHEN mrr > prev_mrr             THEN mrr - prev_mrr ELSE 0 END) AS expansion,
       SUM(CASE WHEN mrr < prev_mrr AND mrr > 0 THEN mrr - prev_mrr ELSE 0 END) AS contraction,
       SUM(CASE WHEN mrr = 0 AND prev_mrr > 0   THEN -prev_mrr ELSE 0 END)      AS churned
FROM m GROUP BY month ORDER BY month;
month | total_mrr | new_mrr | expansion | contraction | churned -----------+------------+-----------+-----------+-------------+--------- 2024-07-01 | 412,000,000| 38,000,000| 14,500,000| -3,200,000 | -21,000,000 2024-08-01 | 448,300,000| 41,000,000| 12,800,000| -2,900,000 | -14,600,000

Net revenue retention (NRR) is built from these same components and is the most important health indicator of a subscription business. An NRR above 100 percent means you grow even without acquiring new customers.

Splitting the change in MRR into four components turns the question “why did growth slow?” from guesswork into analysis: is the problem in acquisition, in expanding existing accounts, or in churn?

What is NPS and what are its limitations?

▾Intermediate
SELECT ROUND(100.0 * COUNT(*) FILTER (WHERE score >= 9) / COUNT(*), 1)
     - ROUND(100.0 * COUNT(*) FILTER (WHERE score <= 6) / COUNT(*), 1) AS nps,
       COUNT(*) FILTER (WHERE score >= 9) AS promoters,
       COUNT(*) FILTER (WHERE score BETWEEN 7 AND 8) AS passives,
       COUNT(*) FILTER (WHERE score <= 6) AS detractors,
       COUNT(*) AS responses
FROM nps_survey WHERE surveyed_at >= CURRENT_DATE - 90;
nps | promoters | passives | detractors | responses -----+-----------+----------+------------+---------- 41.2 | 620 | 310 | 180 | 1,110

Limitations: response bias (usually the very satisfied and the very dissatisfied reply), information lost by turning a 0–10 scale into three groups, and large differences in industry averages that make cross-industry comparison meaningless.

The real value of NPS isn't in the final number but in the open question that follows it. Text analysis of those answers usually helps decisions more than the score itself.

Build a metric tree for an online store.

▾Intermediate
                    Net revenue
                         |
        ┌────────────────┴────────────────┐
      GMV                           Deductions
        |                                |
   ┌────┴────┐             discounts · returns · cancels
Orders       AOV
   |            |
┌──┴──┐    ┌────┴────┐
Visits   Conv. rate Items/order  Price/item
   |          |
Channels   Checkout UX

The benefit of a metric tree is that you can decompose any drop multiplicatively. If revenue drops 10 percent, you know exactly which branch to investigate.

This is one of the best things you can draw on the whiteboard in an interview. It shows that instead of reporting numbers, you understand the causal structure of the business.

How do you set a target for a KPI?

▾Intermediate
  1. Take the baseline from historical data and measure its natural range of fluctuation.
  2. Estimate the theoretical ceiling: how high was the best segment or the best period?
  3. Identify the drivers: which actions are supposed to move this number, and how much effect each one has.
  4. Set the target to be achievable but challenging, with a clear time frame.
  5. Put a guardrail metric next to it so the target isn't met by damaging something else.

A target built from the top with an arbitrary factor (“20 percent growth because the CEO said so”) kills motivation. The target should come out of decomposing the drivers, so the team knows exactly what it needs to do to reach it.

How do you separate the seasonal effect from the real trend?

▾Intermediate
WITH d AS (
  SELECT order_date::date AS dt, SUM(amount) AS rev
  FROM orders WHERE status='paid' GROUP BY 1
)
SELECT dt, rev,
       ROUND(AVG(rev) OVER (ORDER BY dt ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)) AS ma7,
       ROUND(100.0*(rev - LAG(rev,7)  OVER (ORDER BY dt)) / NULLIF(LAG(rev,7)  OVER (ORDER BY dt),0),1) AS wow_pct,
       ROUND(100.0*(rev - LAG(rev,364) OVER (ORDER BY dt)) / NULLIF(LAG(rev,364) OVER (ORDER BY dt),0),1) AS yoy_pct
FROM d ORDER BY dt DESC LIMIT 3;
dt | rev | ma7 | wow_pct | yoy_pct -----------+---------+---------+---------+-------- 2024-08-31 | 700,000 | 563,000 | +8.2 | +23.4 2024-08-30 | 620,000 | 540,000 | -3.1 | +19.8

Comparing with 7 days earlier removes the day-of-week effect, and comparing with 364 days earlier (not 365) keeps the day of week aligned as well. For a more precise separation of trend and seasonality, turn to time series decomposition.

Build and interpret an RFM model for customer segmentation.

▾Intermediate
WITH base AS (
  SELECT customer_id,
         CURRENT_DATE - MAX(order_date)::date AS recency,
         COUNT(*)                              AS frequency,
         SUM(amount)                           AS monetary
  FROM orders WHERE status='paid' GROUP BY 1
), scored AS (
  SELECT *,
         NTILE(4) OVER (ORDER BY recency DESC)   AS r,   -- more recent = higher score
         NTILE(4) OVER (ORDER BY frequency)      AS f,
         NTILE(4) OVER (ORDER BY monetary)       AS m
  FROM base
)
SELECT CASE WHEN r+f+m >= 10 THEN 'champion'
            WHEN r >= 3 AND f <= 2 THEN 'promising newcomer'
            WHEN r <= 2 AND f >= 3 THEN 'at risk of churn'
            WHEN r+f+m <= 4 THEN 'lost'
            ELSE 'regular' END AS segment,
       COUNT(*) AS customers, SUM(monetary) AS revenue
FROM scored GROUP BY 1 ORDER BY revenue DESC;
segment | customers | revenue -------------------+-----------+------------ champion | 380 | 412,000,000 at risk of churn | 640 | 268,000,000 regular | 1,820 | 191,000,000 promising newcomer | 510 | 44,000,000

The “at risk of churn” segment has the most operational value: high-value customers who have recently gone quiet. If you can run only one campaign, this is where it pays off most, not on the champions, who will stay anyway.

Advanced level 9

How does Simpson's paradox show up in KPI reporting?

▾Advanced

When a trend in the overall data is the opposite of the trend in every subgroup.

Version A Version B Desktop 120/1000 = 12.0% 68/500 = 13.6% Mobile 30/1000 = 3.0% 90/2500 = 3.6% -------------------------------------------- Total 150/2000 = 7.5% 158/3000 = 5.3%

Version B is better on both devices, yet looks worse overall — because a larger share of its traffic was mobile, which inherently has a lower conversion rate.

SELECT device, variant,
       COUNT(*) AS users,
       ROUND(100.0 * AVG(converted::int), 2) AS cvr
FROM experiment_users
GROUP BY GROUPING SETS ((device, variant), (variant))
ORDER BY device NULLS LAST, variant;

Prevention: always report every key metric alongside two or three main dimensions (device, channel, new versus returning users). If the population mix changed between two periods, comparing the overall number is meaningless.

What is a guardrail metric and why is it needed?

▾Advanced

A metric that isn't supposed to improve; it just must not get worse. Its role is to prevent local optimization at the cost of overall harm.

Target metricGuardrail metricRisk it covers
Click-through rateRate of quick bounces back to the previous pageClickbait headlines
Number of ordersReturn and cancellation rateExcessive discounting
Support response speedRepeat contact rateClosing tickets prematurely
Short-term revenueNPS and retentionSales pressure

In every A/B test you must specify in advance what the primary metric is, which metrics are guardrails, and what the threshold for stopping the experiment is. Defining these after seeing the results is exactly what is called p-hacking.

How do you choose a North Star Metric?

▾Advanced

The metric that best represents the value customers receive, and whose growth moves in step with sustainable business growth.

  • It measures value from the customer's point of view, not the company's.
  • It predicts future revenue rather than being today's revenue.
  • Different teams can influence it independently.
  • It is hard to game.
Business typeExample North Star
Two-sided marketplaceNumber of successfully completed transactions
ContentDaily meaningful watch time
Enterprise toolNumber of weekly active teams
Online retailOrders delivered without problems

Notice the qualifiers “completed” and “without problems” in the examples. A bad North Star pushes the team toward volume; a good one pushes it toward value.

How is a reliable metrics layer built in an organization?

▾Advanced

A common problem: three dashboards, three different numbers for “revenue”. The solution is a centralized semantic layer.

  1. Written definitions: each metric has one owner, one precise definition, specific filters and a clear unit.
  2. A single implementation: the logic is written in one layer (a dbt model, a semantic model or a central view) and everyone reads from it.
  3. Automated tests: checks for key uniqueness, empty values, allowed ranges and agreement with the financial source.
  4. Versioning: a definition change must be dated and announced, otherwise the historical trend breaks silently.
  5. Culture: if someone sees a different number, there is a clear path for reporting the discrepancy.
CREATE OR REPLACE VIEW metrics.fct_paid_orders AS
SELECT o.order_id, o.customer_id, o.order_date, c.city,
       o.amount - COALESCE(o.discount, 0) AS net_amount
FROM raw.orders o
JOIN raw.customers c USING (customer_id)
WHERE o.status = 'paid' AND o.is_test = false;

If you say in the interview “the first thing I do is write a metrics dictionary, not build a dashboard”, you instantly move up from report builder to senior analyst.

A metric suddenly moved 30 percent. How do you investigate?

▾Advanced

A four-step framework you can memorize:

  1. Is it real? Check the raw input record counts, the last pipeline run time and any schema changes. Most large, sudden jumps are data problems, not market problems.
  2. When did it start? Find the exact break point and compare it with the deployment calendar, metric definition changes and campaigns.
  3. Where is it concentrated? Break it down by channel, device, region, app version and new versus returning users. If the drop is uniform everywhere, the cause is systemic; if it is concentrated, the scope narrows.
  4. Multiplicative decomposition. Use the metric tree: revenue = visits × conversion × AOV. How much has each one changed?
SELECT DATE_TRUNC('day', order_date)::date AS d,
       COUNT(DISTINCT customer_id)         AS buyers,
       COUNT(*)                            AS orders,
       ROUND(AVG(amount))                  AS aov,
       SUM(amount)                         AS revenue
FROM orders
WHERE status = 'paid' AND order_date >= CURRENT_DATE - 21
GROUP BY 1 ORDER BY 1;
d | buyers | orders | aov | revenue -----------+--------+--------+---------+----------- 2024-08-25 | 1,510 | 1,820 | 159,340 | 290,000,000 2024-08-26 | 1,090 | 1,240 | 157,258 | 195,000,000

In this output AOV has stayed almost flat while the number of buyers has dropped — so the problem is at the top of the funnel, not pricing or the basket. Arriving at exactly this one-line sentence is the ultimate goal of the analysis.

How do you tell whether a KPI change is meaningful or normal fluctuation?

▾Advanced
  1. Look at the same metric's historical range of fluctuation: what is its weekly standard deviation? How many times that is the current change?
  2. Compare with the same period last year to remove seasonality.
  3. If there is little data, build a confidence interval; rates based on small samples fluctuate a lot.
  4. Look at the multi-period trend, not a single point.
WITH w AS (
  SELECT DATE_TRUNC('week', order_date) AS wk, SUM(amount) AS rev
  FROM orders WHERE status='paid' GROUP BY 1
), stats AS (
  SELECT wk, rev,
         AVG(rev)    OVER (ORDER BY wk ROWS BETWEEN 12 PRECEDING AND 1 PRECEDING) AS avg_12,
         STDDEV(rev) OVER (ORDER BY wk ROWS BETWEEN 12 PRECEDING AND 1 PRECEDING) AS sd_12
  FROM w
)
SELECT wk, rev, ROUND(avg_12) AS baseline,
       ROUND((rev - avg_12) / NULLIF(sd_12, 0), 2) AS z_score
FROM stats ORDER BY wk DESC LIMIT 4;
wk | rev | baseline | z_score -----------+-------------+-------------+-------- 2024-08-26 | 195,000,000 | 268,000,000 | -2.84 2024-08-19 | 271,000,000 | 266,000,000 | 0.19

A z-score of minus 2.8 means this week is outside the usual range of fluctuation and worth investigating. If it were between minus 2 and plus 2, it's probably just noise, and raising an alert would erode trust in the dashboard.

For a new business, where do you start defining KPIs?

▾Advanced
  1. Understand the business model: where the money comes from, where value is created, how long the purchase cycle is.
  2. Choose a North Star metric that represents customer value.
  3. Break it into a metric tree so each team knows which branch it affects.
  4. Add guardrail metrics so local optimization doesn't happen.
  5. Write the definitions down and build a single implementation.
  6. Keep three to five metrics at the management level. A dashboard with thirty numbers is as useless as one with none.

If the interviewer asks this as an open question, first ask two or three clarifying questions: the company's growth stage, its revenue model, and who the main decision maker is. Starting with a list of metrics without understanding the context is the weakest possible answer.

Compare attribution models for marketing channels.

▾Advanced
ModelLogicBias
Last clickAll credit to the last touchIgnores awareness channels
First clickAll credit to the first touchIgnores closing channels
LinearEqual splitTreats every touch as equally valuable
Time decayCloser touches get more weightDepends on the chosen decay rate
Data-drivenEach channel's statistical contributionNeeds data volume and a model
Incrementality testTurn the channel off and measure the effectExpensive, but the only causal method

The key sentence: “Attribution models divide up correlation, not causation. If a big budget decision is at stake, an incrementality test — even a small one — is worth more than any model.”

Design a management dashboard from scratch.

▾Advanced

A three-layer structure that always works:

  1. First layer — three to five KPIs with period comparisons and status against target.
  2. Second layer — a breakdown of those KPIs following the metric tree: if revenue dropped, which component is to blame.
  3. Third layer — dimensions and details for root-cause analysis, usually on a drillthrough page.

Practical rules: each visual's title states the finding, not the field name; every number has a comparison; set a sensible default date filter; show the last refresh time; and keep metric definitions one click away.

Before designing, ask: “What decision is this dashboard meant to support, and who makes that decision?” If there is no clear answer, the problem isn't in the design but in the definition of the need.

Machine Learning

In data analysis and data engineering roles, what's expected of machine learning is usually not mathematical depth; it's the ability to translate a business problem correctly into a learning problem, choose a metric that fits the cost of errors, and recognize common traps such as data leakage. The key terms are defined in the review sheet at the start of the section.

Machine learning review sheet

Every term with a full definition, not just a formulaPYTHON
Confusion matrix Predicted Pos Neg Actual Pos TP FN Actual Neg FP TN
TP (True Positive): actually positive, and the model said positive. TN (True Negative): actually negative, and the model said negative. FP (False Positive): negative, but the model said positive — a false alarm. FN (False Negative): positive, but the model said negative — a missed case. Every classification metric is built from these four numbers.
Accuracy = (TP + TN) / (TP + TN + FP + FN)
Accuracy: what percentage of all predictions were right. It is only meaningful when the two classes are roughly the same size. If 95 percent of the data is negative, a model that always says “negative” has 95 percent accuracy and produces no value at all.
Precision = TP / (TP + FP)
Precision: of the cases the model flagged as positive, what percentage were actually positive. It matters when false alarms are costly — for example blocking a legitimate transaction or contacting a customer for no reason.
Recall = TP / (TP + FN)
Recall, or sensitivity: of the actual positive cases, what percentage the model found. It matters when missing a case is costly — such as detecting disease, fraud or a customer about to churn.
F1 = 2 × (P × R) / (P + R)
The harmonic mean of precision and recall, not the simple mean. The harmonic mean is chosen because if either metric is very low, F1 stays low too, and you can't make up for one by sacrificing the other.
Specificity = TN / (TN + FP)
Specificity: of the actual negative cases, what percentage were correctly identified. It is the complement of recall and is used a lot in medicine and quality control.
ROC-AUC
The area under the curve of true positive rate against false positive rate, across all possible thresholds. Simple interpretation: the probability that the model scores a random positive example higher than a random negative one. A value of 0.5 means random and 1 means perfect separation.
PR-AUC
The area under the precision–recall curve. On heavily imbalanced data it is a more honest metric than ROC-AUC, because the large number of true negatives doesn't make the number artificially optimistic.
RMSE · MAE · MAPE
Regression error metrics. MAE is the mean absolute error and is easy to interpret. RMSE squares the errors, so it is more sensitive to outliers. MAPE measures error as a percentage and becomes unstable when the actual value approaches zero.
R² = 1 − SS_res / SS_tot
Coefficient of determination: what share of the target variable's variance the model explains. A value of zero means the model does no better than predicting the mean, and a negative value means it does worse.
Overfitting · Underfitting
Overfitting: the model has memorized the noise in the training data; the sign is excellent performance on training data and poor performance on new data. Underfitting: the model hasn't even captured the main pattern; performance is poor on both.
Bias–Variance
Bias error comes from a model that is too simple, and variance error from excessive sensitivity to the training data. Reducing one usually increases the other; the goal is to find the balance point.
Data Leakage
Data leakage: information entering the model that won't be available at real prediction time. The result is outstanding performance in testing and complete failure in production. Its most common form is preprocessing on the whole dataset before splitting.
train_test_split(..., stratify=y)
Splitting data into training and test sets while preserving class proportions in both. Essential for imbalanced data, otherwise the test set may end up with very few positive examples.
Cross-Validation · TimeSeriesSplit
Cross-validation: the data is split into k folds and the model is trained k times for a more stable evaluation. For time series you must use the time-based version, which always trains on the past and evaluates on the future.
Pipeline(pre, clf)
Putting preprocessing and the model in a single chain. Its most important benefit is that in each validation fold, preprocessing is fitted only on that fold's training data, which prevents data leakage.
L1 (Lasso) · L2 (Ridge)
Regularization to rein in overfitting. L1 sets some features' coefficients exactly to zero, effectively performing feature selection; L2 shrinks all coefficients but doesn't eliminate them.
Random Forest · XGBoost · LightGBM
Tree-based models, the default choice for tabular data. A random forest builds trees in parallel and independently; boosting methods build trees one after another, with each tree correcting the previous one's errors.
KMeans · DBSCAN
Two clustering methods. KMeans takes the number of clusters up front and builds spherical clusters; DBSCAN finds the number itself, accepts arbitrary shapes and sets outliers aside as noise.
PCA · SHAP
PCA reduces dimensions by building linear combinations that keep the most variance. SHAP determines each feature's contribution to the prediction for a specific example and is the main tool for interpreting complex models.
SMOTE · class_weight="balanced"
Two ways of dealing with imbalanced data. SMOTE creates synthetic examples of the minority class; class weighting makes errors on the minority class heavier in the loss function. The second is simpler and should usually be tried first.

Basic level 15

How do supervised, unsupervised and reinforcement learning differ?

▾Basic
  • Supervised: we have labels. Classification (customer churn) and regression (sales forecasting).
  • Unsupervised: we have no labels. Customer clustering, dimensionality reduction, anomaly detection.
  • Reinforcement: an agent learns by trial and error and receiving rewards. Dynamic pricing, routing.

In most business problems, the right first question is: “Do we even have reliable labels?” Many ML projects run aground right here.

What is overfitting and how do you detect it?

▾Basic

Instead of learning the pattern, the model memorizes the details and noise of the training data; it performs excellently on training data and poorly on new data.

from sklearn.model_selection import cross_val_score
print("train:", model.score(X_train, y_train))
print("test :", model.score(X_test,  y_test))
print("cv   :", cross_val_score(model, X, y, cv=5).mean())
train: 0.987 test : 0.742 cv : 0.751

Ways to counter it: more data, fewer features, regularization (L1/L2), limiting tree depth, early stopping, and cross-validation.

The gap of about 25 points between training and test in the output above is a clear sign of overfitting.

Why do we separate training and test data?

▾Basic
from sklearn.model_selection import train_test_split
X_tr, X_te, y_tr, y_te = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42)
  • Evaluation on unseen data is the only honest estimate of real performance.
  • stratify=y preserves the class proportions in both parts.
  • random_state makes the result reproducible.
Never split time-based data randomly. You must split by time, otherwise the model gets information from the future and the result looks unrealistically good.

Read and interpret a confusion matrix.

▾Basic
from sklearn.metrics import confusion_matrix, classification_report
print(confusion_matrix(y_te, y_pred))
print(classification_report(y_te, y_pred, digits=3))
[[8600 240] [ 380 780]] precision recall f1-score support 0 0.958 0.973 0.965 8840 1 0.765 0.672 0.716 1160

We missed 380 customers who actually churned (false negatives) and wrongly flagged 240 as at risk of churn (false positives).

The next question is always: which error is more expensive? If losing a customer is costly, we should lower the threshold to raise recall, even at the cost of lower precision.

How do linear and logistic regression differ?

▾Basic
LinearLogistic
OutputA continuous numberA probability between zero and one
Loss functionSquared errorLog likelihood
UseForecasting sales amountProbability of churn or a click
import numpy as np
coefs = dict(zip(feature_names, model.named_steps["clf"].coef_[0]))
for k, v in sorted(coefs.items(), key=lambda x: -abs(x[1]))[:3]:
    print(f"{k:20s} coef={v:+.3f}  odds_ratio={np.exp(v):.2f}")
days_since_last coef=+0.842 odds_ratio=2.32 support_tickets coef=+0.415 odds_ratio=1.51 tenure_months coef=-0.298 odds_ratio=0.74

The big advantage of logistic regression is interpretability: an odds ratio of 2.32 means that a one-unit increase in this feature more than doubles the odds of churn. That is very valuable when talking to stakeholders.

What stages does a machine learning project consist of?

▾Basic
  1. Problem definition: what is the business question, and how is success measured.
  2. Data collection: sources, time range, quality and labels.
  3. Exploratory analysis: distributions, missing values, outliers, correlation.
  4. Feature engineering: building meaningful variables from the raw data.
  5. Training and validation: a baseline model, then more complex models.
  6. Evaluation: with a metric that fits the cost of errors, not default accuracy.
  7. Deployment and monitoring: serving, logging predictions, detecting drift.

In practice about 70 percent of the time goes into steps 2 to 4. If you say in the interview that most of your time goes into the data rather than choosing an algorithm, you've said exactly what real experience shows.

How do classification and regression differ?

▾Basic
ClassificationRegression
OutputA label or class probabilityA continuous number
ExampleWill this customer churn?How much will we sell next month?
MetricAUC, F1, precision, recallRMSE, MAE, MAPE, R²
Common algorithmsLogistic, trees, boostingLinear, trees, boosting

Some problems can be modeled either way. For example, instead of predicting the exact purchase amount (regression), predicting whether a customer falls into the high-spender group (classification) is sometimes enough for the decision and gives more stable answers.

What do feature and label mean?

▾Basic
import pandas as pd
df = pd.DataFrame({
    "tenure_months":  [3, 24, 11],     # feature
    "orders_90d":     [1, 9, 4],       # feature
    "support_tickets":[3, 0, 1],       # feature
    "churned":        [1, 0, 0],       # label (target)
})
X = df.drop(columns="churned")
y = df["churned"]
print(X.shape, y.shape)
(3, 3) (3,)

The naming convention of X for the feature matrix and y for the target vector is almost universal. Following it makes the code instantly readable for anyone who has worked with scikit-learn.

What is a baseline model and why do we build it first?

▾Basic
from sklearn.dummy import DummyClassifier
from sklearn.metrics import roc_auc_score

dummy = DummyClassifier(strategy="prior").fit(X_tr, y_tr)
print("baseline AUC:", roc_auc_score(y_te, dummy.predict_proba(X_te)[:, 1]))
print("model    AUC:", roc_auc_score(y_te, model.predict_proba(X_te)[:, 1]))
baseline AUC: 0.500 model AUC: 0.842

The baseline can be even simpler: “always predict the majority class”, or in time series “the previous period's value”.

Without a baseline, an 85 percent accuracy figure is meaningless. If 90 percent of the data is in the negative class, a model that always says “negative” also has 90 percent accuracy and produces no value.

Which commands do you start exploratory data analysis with?

▾Basic
print(df.shape)
print(df.dtypes)
print(df.isna().mean().sort_values(ascending=False).head())
print(df.describe().T)
print(df["churned"].value_counts(normalize=True))
print(df.corr(numeric_only=True)["churned"].sort_values(ascending=False).head())
(50000, 12) churned 0 0.884 1 0.116 days_since_last_order 0.412 support_tickets_90d 0.238 tenure_months -0.301

The fourth line is the most important: the class balance. With 11.6 percent positives, you know from the start that accuracy isn't a suitable metric and you should turn to AUC or precision and recall.

How do correlation and causation differ?

▾Basic

Correlation means two variables change together. Causation means a change in one causes a change in the other. There are three alternative explanations for any correlation:

  • A hidden variable: both are effects of a third factor.
  • Reverse direction: Y actually caused X.
  • Chance: especially when you test many relationships at once.

A practical example: customers who installed the app buy more. Did the app cause the purchases, or do loyal customers both install the app and buy more? The only way to answer is a randomized experiment, not analyzing existing data.

What does feature engineering mean? Give a few practical examples.

▾Basic
df["days_since_last"]   = (snapshot - df.last_order_date).dt.days
df["orders_per_month"]  = df.total_orders / df.tenure_months.clip(lower=1)
df["aov"]               = df.total_spend / df.total_orders.clip(lower=1)
df["is_weekend_buyer"]  = (df.weekend_orders / df.total_orders) > 0.5
df["log_spend"]         = np.log1p(df.total_spend)          # reduce skewness
df["ticket_trend"]      = df.tickets_30d - df.tickets_30_60d
df["hour_sin"]          = np.sin(2*np.pi*df.hour/24)        # cyclical feature
df["hour_cos"]          = np.cos(2*np.pi*df.hour/24)

The last two features are an important trick: hour 23 and hour 0 are actually next to each other but numerically far apart. The sine and cosine transform passes this cyclical continuity on to the model. The same applies to month and day of week.

What is a validation set and how does it differ from a test set?

▾Basic
X_tmp, X_test, y_tmp, y_test = train_test_split(X, y, test_size=0.2, stratify=y, random_state=42)
X_tr, X_val, y_tr, y_val = train_test_split(X_tmp, y_tmp, test_size=0.25, stratify=y_tmp, random_state=42)
print(len(X_tr), len(X_val), len(X_test))   # 60% / 20% / 20%
30000 10000 10000
  • Training: the model learns on it.
  • Validation: model selection and hyperparameter tuning are done on it.
  • Test: used only once, at the end, to report final performance.
If you make decisions on the test set again and again, you've effectively turned it into a validation set and the final number becomes optimistic. This indirect leakage is a common mistake in real projects.

How do you turn a model's output probability into a decision?

▾Basic
proba = model.predict_proba(X_te)[:, 1]

for th in [0.2, 0.3, 0.5, 0.7]:
    pred = proba > th
    tp = ((pred) & (y_te==1)).sum(); fp = ((pred) & (y_te==0)).sum()
    fn = ((~pred) & (y_te==1)).sum()
    print(f"Threshold {th}: precision={tp/(tp+fp):.2f}  recall={tp/(tp+fn):.2f}  targeted={pred.sum()}")
Threshold 0.2: precision=0.42 recall=0.88 targeted=2430 Threshold 0.3: precision=0.58 recall=0.74 targeted=1480 Threshold 0.5: precision=0.77 recall=0.51 targeted=770 Threshold 0.7: precision=0.89 recall=0.28 targeted=365

A threshold of 0.5 isn't sacred; it's just the library default. The right choice depends on the cost of each error and operational capacity: if the team can only make 500 calls a day, set the threshold so it yields that many.

Run a simple model from start to evaluation.

▾Basic
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score, classification_report

num = ["recency", "frequency", "monetary"]
cat = ["city", "channel"]

pre = ColumnTransformer([
    ("num", Pipeline([("imp", SimpleImputer(strategy="median")),
                      ("sc",  StandardScaler())]), num),
    ("cat", OneHotEncoder(handle_unknown="ignore", min_frequency=0.01), cat),
])
model = Pipeline([("pre", pre), ("clf", LogisticRegression(max_iter=1000, class_weight="balanced"))])
model.fit(X_tr, y_tr)

p = model.predict_proba(X_te)[:, 1]
print("AUC:", round(roc_auc_score(y_te, p), 3))
print(classification_report(y_te, p > 0.5, digits=3))
AUC: 0.842 precision recall f1-score 0 0.958 0.973 0.965 1 0.765 0.672 0.716

Memorize this structure: everything inside one Pipeline. That way preprocessing is fitted separately in each fold, no leakage happens, and the final model can be used on new raw data with a single call.

Intermediate level 13

Explain the bias–variance trade-off.

▾Intermediate
  • High bias (underfitting): the model is simpler than reality; weak on both training and test.
  • High variance (overfitting): the model is overly sensitive to the training data.
SituationSignAction
High biasBoth scores lowA more complex model, more features, less regularization
High varianceA large gap between training and testMore data, regularization, a simpler model

The learning curve is the best diagnostic tool: if the two curves don't converge as data increases, the problem is bias, not a lack of data.

What is cross-validation, and how is it done for time series?

▾Intermediate
from sklearn.model_selection import StratifiedKFold, TimeSeriesSplit, cross_val_score

# Independent data
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

# Time series: training always comes before testing
tscv = TimeSeriesSplit(n_splits=5)
for tr, te in tscv.split(X):
    print(f"train: 0..{tr[-1]}   test: {te[0]}..{te[-1]}")
train: 0..1665 test: 1666..2498 train: 0..2498 test: 2499..3331 train: 0..3331 test: 3332..4164

Using ordinary KFold on a time series is one of the most frequent interview mistakes. The model trains on data that comes after the test data in time.

What does data leakage mean? Give three examples.

▾Intermediate
  • Scaling or imputing missing values on the whole dataset before splitting into training and test.
  • A feature that is only recorded after the target event occurs; for example “subscription cancellation date” in a churn prediction model.
  • An identifier that happens to correlate with the target, such as a row number sorted by the label.
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler

pipe = Pipeline([
    ("imp",   SimpleImputer(strategy="median")),
    ("scale", StandardScaler()),
    ("clf",   model),
])
# Inside cross_val_score, each fold is fitted separately → no leakage
scores = cross_val_score(pipe, X, y, cv=5, scoring="roc_auc")

The classic sign of leakage: accuracy that is “too good”, for example an AUC above 0.98 on an inherently noisy problem. The right first reaction is suspicion, not celebration.

How do you handle imbalanced data?

▾Intermediate
  1. Change the metric: use PR-AUC, recall or F1 instead of accuracy.
  2. Use class weights: class_weight="balanced", or scale_pos_weight in XGBoost.
  3. Resampling: undersample the majority class or synthetically oversample the minority with SMOTE — only on the training data.
  4. Tune the threshold; it doesn't have to be 0.5.
from sklearn.metrics import precision_recall_curve
p, r, th = precision_recall_curve(y_te, proba)
f1 = 2*p*r / (p + r + 1e-9)
best = th[f1[:-1].argmax()]
print(f"Best threshold: {best:.3f}  F1={f1.max():.3f}")
Best threshold: 0.283 F1=0.741
Never run SMOTE before splitting the data. The synthetic examples get built from test rows and the evaluation becomes invalid.

How do you encode categorical features?

▾Intermediate
MethodBest forRisk
One-HotFew categories, linear modelsDimension explosion
OrdinalTruly ordinal categoriesImposing a false order
Target EncodingHigh cardinalityLeakage, if done without CV
Native categoricalLightGBM and CatBoostLimited to that library
from sklearn.preprocessing import OneHotEncoder
enc = OneHotEncoder(handle_unknown="ignore", min_frequency=0.01, sparse_output=False)

Two key arguments: handle_unknown="ignore" for new categories at prediction time, and min_frequency for merging rare categories that only add noise.

When is feature scaling necessary?

▾Intermediate
  • Necessary: regularized regression, SVM, KNN, KMeans, PCA, neural networks — every algorithm that depends on distances or gradient magnitudes.
  • Not necessary: decision trees and tree-based models (Random Forest, XGBoost), which only look at the order of values.
from sklearn.preprocessing import StandardScaler, RobustScaler, MinMaxScaler
# Data with many outliers → RobustScaler (based on the median and IQR)

If a feature's distribution is skewed (like purchase amounts), a log transform before scaling usually helps more than the scaling itself.

Compare a decision tree, a random forest and gradient boosting.

▾Intermediate
Decision TreeRandom ForestGradient Boosting
ConstructionOne treeParallel trees on random samplesSequential trees, each correcting the previous one's errors
VarianceHighLowMedium
Typical accuracyLowGoodUsually the best on tabular data
Parameter tuningSimpleSimpleSensitive
InterpretabilityHighMediumLow (can be compensated for with SHAP)

The practical answer: “I start with logistic regression as the baseline, then try LightGBM. If the difference isn't meaningful, I choose the simpler, more interpretable model.”

Cluster customers and choose the number of clusters.

▾Intermediate
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import silhouette_score

Xs = StandardScaler().fit_transform(rfm[["recency", "frequency", "monetary"]])
for k in range(2, 8):
    km = KMeans(n_clusters=k, n_init=10, random_state=42).fit(Xs)
    print(k, round(km.inertia_), round(silhouette_score(Xs, km.labels_), 3))
2 18420 0.412 3 12180 0.437 4 9310 0.451 5 8020 0.398

The elbow method (drop in inertia) and the silhouette score both agree on k=4.

The final criterion for choosing k isn't technical: the clusters must mean something to the marketing team, and it must be possible to design a different action for each one. Eight mathematically correct but indistinguishable clusters are useless.

Read a learning curve and make a decision.

▾Intermediate
from sklearn.model_selection import learning_curve
sizes, tr, va = learning_curve(model, X, y, cv=5, scoring="roc_auc",
                               train_sizes=np.linspace(0.1, 1.0, 8))
for n, a, b in zip(sizes, tr.mean(1), va.mean(1)):
    print(f"{n:6.0f}  train={a:.3f}  valid={b:.3f}  gap={a-b:.3f}")
4000 train=0.941 valid=0.762 gap=0.179 12000 train=0.897 valid=0.812 gap=0.085 24000 train=0.871 valid=0.834 gap=0.037 40000 train=0.862 valid=0.841 gap=0.021

Interpretation: the gap is closing and the validation curve is still rising slightly, so more data still helps, but with diminishing returns.

If the two curves converge early and at a low level, the problem is bias and more data is useless; you need better features or a stronger model. Telling these two situations apart determines the project's next decision.

How do you do feature selection?

▾Intermediate
  • Filter methods: remove features with near-zero variance, very high correlation with each other, or a high share of missing values.
  • Wrapper methods: recursive feature elimination, measuring the effect on performance.
  • Embedded methods: L1 regularization or feature importance in tree models.
  • Permutation-based: shuffle a column and measure the drop in performance.
from sklearn.inspection import permutation_importance
r = permutation_importance(model, X_val, y_val, n_repeats=10,
                           scoring="roc_auc", random_state=42)
imp = pd.Series(r.importances_mean, index=X_val.columns).sort_values(ascending=False)
print(imp.head(5).round(4))
print("Can be removed:", list(imp[imp <= 0].index))

Permutation importance is preferable to tree importance because it has no bias toward high-cardinality features and is computed on validation data, not training data. A feature with negative importance is actually doing harm by being there.

What does probability calibration mean and why does it matter?

▾Intermediate

A model is calibrated when, if it says 30 percent probability, about 30 percent of those cases actually happen.

from sklearn.calibration import CalibratedClassifierCV, calibration_curve

prob_true, prob_pred = calibration_curve(y_te, proba, n_bins=10)
for p, t in zip(prob_pred, prob_true):
    print(f"Predicted {p:.2f}  →  actual {t:.2f}")

cal = CalibratedClassifierCV(model, method="isotonic", cv=5).fit(X_tr, y_tr)
Predicted 0.15 → actual 0.09 Predicted 0.35 → actual 0.27 Predicted 0.75 → actual 0.81

If the model's output feeds into a financial calculation — for example “expected value = probability × profit” — calibration is essential. For pure ranking it doesn't matter. Boosting models are usually poorly calibrated, while logistic regression is relatively well calibrated.

How do you validate and interpret a clustering?

▾Intermediate
from sklearn.metrics import silhouette_score, davies_bouldin_score

km = KMeans(n_clusters=4, n_init=10, random_state=42).fit(Xs)
df["cluster"] = km.labels_
print("silhouette:", round(silhouette_score(Xs, km.labels_), 3))
print("davies-bouldin:", round(davies_bouldin_score(Xs, km.labels_), 3))

# Profile of each cluster — the interpretive part, more important than the number itself
print(df.groupby("cluster")[["recency","frequency","monetary"]].median().round(0))
print(df.cluster.value_counts(normalize=True).round(3))
recency frequency monetary cluster 0 12.0 9.0 4200000 1 45.0 3.0 1100000 2 210.0 1.0 280000 3 88.0 2.0 640000

A numeric metric only tells you whether the clusters are separated. Real validity comes when you can give each cluster a business name and design a different action for each. If two clusters call for the same action, they should be merged.

How do you do anomaly detection?

▾Intermediate
from sklearn.ensemble import IsolationForest

iso = IsolationForest(contamination=0.01, random_state=42, n_estimators=200)
df["anomaly"] = iso.fit_predict(X) == -1
df["score"]   = -iso.score_samples(X)

print(df.anomaly.mean().round(4))
print(df.nlargest(5, "score")[["order_id","amount","items","country","score"]])
0.0100 order_id amount items country score 88 904412 84000000 41 RU 0.712 21 901220 12000000 1 IR 0.688

In fraud detection, real labels are usually scarce and arrive late, so unsupervised methods are a good starting point. But the contamination parameter is an assumption, not a fact — align it with the review team's capacity, not with a guess.

Advanced level 13

How do you do hyperparameter tuning?

▾Advanced
from sklearn.model_selection import RandomizedSearchCV
from scipy.stats import randint, uniform
import lightgbm as lgb

params = {
    "clf__num_leaves":     randint(15, 120),
    "clf__max_depth":      randint(3, 12),
    "clf__learning_rate":  uniform(0.01, 0.2),
    "clf__n_estimators":   randint(200, 1200),
    "clf__subsample":      uniform(0.6, 0.4),
    "clf__colsample_bytree": uniform(0.6, 0.4),
}
search = RandomizedSearchCV(
    pipe, params, n_iter=40, cv=5, scoring="roc_auc",
    n_jobs=-1, random_state=42, refit=True)
search.fit(X_tr, y_tr)
print(search.best_params_, round(search.best_score_, 4))
{'clf__learning_rate': 0.043, 'clf__max_depth': 7, 'clf__num_leaves': 63, ...} 0.8712

Random search usually gives better results than grid search for the same budget. For larger spaces, Bayesian optimization with Optuna is the next option. An important point: tuning must happen inside CV, not on the test data.

How do you interpret a black-box model for a stakeholder?

▾Advanced
import shap
explainer   = shap.TreeExplainer(model)
shap_values = explainer.shap_values(X_te)
shap.summary_plot(shap_values, X_te, plot_type="bar")

# Feature contributions for one specific customer
shap.force_plot(explainer.expected_value, shap_values[42], X_te.iloc[42])
Mean |SHAP|: days_since_last_order 0.184 support_tickets_90d 0.096 avg_order_value 0.071
  • Global level: which features have the most effect.
  • Individual level: why this particular customer was rated high-risk — exactly what the support team needs in order to act.
  • Permutation importance is a lighter, model-agnostic alternative.
Feature importance isn't a causal relationship. If you say “the number of support tickets causes churn”, you've gone beyond what the model shows. Be sure to raise the distinction between correlation and causation.

Forecast monthly sales. What approach do you take?

▾Advanced
  1. Build a baseline: last month's value, or the average of the same month in previous years. Any complex model has to beat this.
  2. Decompose the series: trend, seasonality, residual.
  3. Build features: lags, moving averages, holidays, calendar events (for example Nowruz and Yalda in Iran).
  4. Choose a model: SARIMA or Prophet for univariate series, LightGBM with lag features for multivariate problems.
  5. Evaluate with a rolling time window and MAE or MAPE.
df["lag_1"]  = df.sales.shift(1)
df["lag_12"] = df.sales.shift(12)
df["ma_3"]   = df.sales.shift(1).rolling(3).mean()
df["month"]  = df.index.month
Model MAE MAPE naive (lag_1) 8.4M 11.2% SARIMA 6.1M 8.0% LightGBM 5.3M 6.9%

Always compare with the baseline. If the complex model is only 1 percent better than “same as last month”, its maintenance cost isn't justified — and saying so in the interview shows professional maturity.

How do L1 and L2 regularization differ?

▾Advanced
  • L1 (Lasso): penalizes the absolute value of coefficients; it sets some coefficients exactly to zero, so it also performs feature selection.
  • L2 (Ridge): penalizes the square of coefficients; it shrinks them all but doesn't zero them. It copes better with correlated features.
  • Elastic Net: a combination of both.
from sklearn.linear_model import LogisticRegression
LogisticRegression(penalty="l1", C=0.1, solver="liblinear")   # smaller C = stronger penalty

Note that in scikit-learn the C parameter is the inverse of regularization strength. Getting its direction wrong is a common mistake that interviewers may ask about on purpose.

How do you handle missing values in modeling?

▾Advanced
  1. First understand the cause: is it random, dependent on another variable, or meaningful in itself?
  2. If missingness is itself a signal, build an indicator column.
  3. Impute with the median or mode inside the pipeline, not before splitting the data.
  4. Boosting models such as LightGBM and XGBoost handle missing values natively.
  5. Drop rows only when the percentage is very small and the missingness is random.
X["income_missing"] = X["income"].isna().astype(int)
# Then in the Pipeline:
SimpleImputer(strategy="median")

A concrete example: on a sign-up form, a blank income field may itself be a strong predictor of behavior. Filling it blindly destroys that information.

How do you monitor a model after deployment?

▾Advanced
  • Data drift: the distribution of input features has changed; monitored with a KS test or the PSI index.
  • Concept drift: the relationship between features and target has changed; only detectable once real labels arrive.
  • Performance monitoring: the model's metric on newly labeled data, broken down by key segments.
  • Operational monitoring: response latency, error rate, percentage of out-of-range inputs.
  • Retraining policy: a fixed schedule, or triggered by a performance-drop threshold.
from scipy.stats import ks_2samp
stat, p = ks_2samp(train_feature, live_feature)
if p < 0.01:
    log.warning("Feature distribution has changed")

An important point that is rarely mentioned: you must store the predictions and the features at run time, otherwise when the real labels arrive you can't evaluate retrospectively.

How do you turn a churn model into a business decision?

▾Advanced

The model gives probabilities; the decision has to be built. The steps:

  1. Estimate the value of each customer and the cost of each retention action.
  2. Compute the expected profit of each threshold, not just the statistical metric.
  3. Consider operational capacity: how many calls can the support team make per day?
  4. Measure the real effect with a control group, not with the model's accuracy.
value, cost, uplift = 4_000_000, 300_000, 0.30
best = max(
    ((th, ((proba > th) & (y_te == 1)).sum() * value * uplift
          - (proba > th).sum() * cost)
     for th in np.arange(0.05, 0.95, 0.05)),
    key=lambda x: x[1])
print(f"Optimal threshold {best[0]:.2f}  expected profit {best[1]:,.0f}")
Optimal threshold 0.35 expected profit 1,842,000,000

The key sentence: “A model that isn't deployed and doesn't lead to a decision has zero business value, however high its AUC.”

What does PCA do and when is it appropriate?

▾Advanced
from sklearn.decomposition import PCA
p = PCA(n_components=0.95).fit(X_scaled)     # keep 95% of the variance
print(p.n_components_, p.explained_variance_ratio_[:5].round(3))
14 [0.312 0.184 0.096 0.071 0.048]

Suitable for: reducing dimensions before distance-based algorithms, removing severe multicollinearity, and visualizing high-dimensional data.

Its cost is lost interpretability: the components are linear combinations of the original features and have no business meaning. For models that must be explained, feature selection is usually better than PCA. Also, scaling before PCA is mandatory.

When should you say “this problem doesn't need machine learning”?

▾Advanced
  • A simple rule works: a few clear business conditions beat a model that nobody trusts.
  • There isn't enough reliable labeled data.
  • The target definition is vague; “churn” still has no precise definition.
  • There's no deployment and monitoring infrastructure, so the model stays in a notebook.
  • The cost of errors is high and the model isn't interpretable.
  • The problem is really a reporting problem, not a prediction problem.

This answer lands very well in an interview: it shows you see the tool as serving the problem, not the other way round. Add that you always start with the simplest possible solution and only accept complexity when it brings a measurable gain.

The model performs differently on subgroups. What do you do?

▾Advanced
for col in ["city_group", "channel", "is_new_customer"]:
    print(f"\n--- {col} ---")
    for g, idx in X_te.groupby(col).groups.items():
        yy, pp = y_te.loc[idx], proba[X_te.index.get_indexer(idx)]
        if yy.nunique() < 2: continue
        print(f"{str(g):15s} n={len(idx):6d}  AUC={roc_auc_score(yy, pp):.3f}  base={yy.mean():.3f}")
--- channel --- web n= 6200 AUC=0.871 base=0.118 app n= 3100 AUC=0.844 base=0.131 call_center n= 700 AUC=0.612 base=0.204

The course of action: check whether that subgroup has little data, lacks relevant features, or behaves inherently differently. Options: add features specific to that group, a separate model, or state the limitation explicitly and exclude that group from the scope of use.

Reporting performance only in aggregate hides weakness in subgroups. Beyond quality, this is also a fairness issue: a model that works worse for a particular group can produce unfair decisions.

How do you deploy a model?

▾Advanced
PatternBest forConsideration
Batch scoringChurn, credit scores, nightly recommendationsThe simplest and most common
Real-time service (API)Fraud detection, real-time rankingNeeds low latency and monitoring
Embedded in an applicationA lightweight model on the deviceHard to update
StreamingReacting to eventsThe most complex
import joblib
joblib.dump({"model": model, "features": list(X.columns),
             "trained_at": str(pd.Timestamp.utcnow()),
             "version": "1.3.0"}, "churn_v1.3.0.joblib")

Always store the list and order of features alongside the model. The most common deployment mistake is that at prediction time the columns arrive in a different order and the model silently returns meaningless results without raising any error.

Explain a simple recommender system.

▾Advanced
  • Popularity: the best-sellers; a simple baseline that every other method must beat.
  • Content-based: similarity of product attributes; works for new products.
  • Collaborative filtering: “people who bought this also bought that”; powerful but has the cold-start problem.
  • Hybrid: in practice almost always the right choice.
# Simple co-occurrence: an effective, explainable baseline
pairs = (orders.merge(orders, on="order_id")
               .query("product_id_x < product_id_y")
               .groupby(["product_id_x","product_id_y"]).size()
               .rename("together").reset_index()
               .sort_values("together", ascending=False))

Recommenders are evaluated with ranking metrics such as precision@k and NDCG, but the final measure is always an online test: click-through rate and incremental revenue. A model that is better offline isn't necessarily better online.

Tune hyperparameters with Bayesian optimization.

▾Advanced
import optuna
from sklearn.model_selection import cross_val_score

def objective(trial):
    params = {
        "num_leaves":      trial.suggest_int("num_leaves", 15, 150),
        "max_depth":       trial.suggest_int("max_depth", 3, 12),
        "learning_rate":   trial.suggest_float("learning_rate", 0.01, 0.2, log=True),
        "n_estimators":    trial.suggest_int("n_estimators", 200, 1500),
        "subsample":       trial.suggest_float("subsample", 0.6, 1.0),
        "reg_lambda":      trial.suggest_float("reg_lambda", 1e-3, 10, log=True),
    }
    clf = lgb.LGBMClassifier(**params, random_state=42)
    return cross_val_score(clf, X_tr, y_tr, cv=5, scoring="roc_auc").mean()

study = optuna.create_study(direction="maximize")
study.optimize(objective, n_trials=60, timeout=1800)
print(study.best_value.round(4), study.best_params)

The advantage of Bayesian optimization is that it learns from previous results and explores promising regions more, so it reaches a better result with fewer trials. But always ask whether that half-percent improvement is worth the complexity and compute time.

Big Data

When data volume outgrows a single machine, the tools and concepts change. This section focuses on Spark, storage formats, lake and warehouse architecture, and orchestration — the backbone of data engineering work.

Big Data cheat sheet

Terms and patterns you should be able to explain without hesitationPYTHON
HDFS / Object Storage
Distributed storage; today mostly S3 and its cloud equivalents
Spark Driver / Executor
The brain and the workers; the driver builds the plan, executors run it
Transformation vs Action
Transformations are lazy, actions trigger execution
Narrow vs Wide
No data movement vs requiring a shuffle
Shuffle
The most expensive operation: disk writes and network transfer
Partition
The unit of parallelism; each partition is one task
broadcast(df)
Ships a small table to every node to eliminate a shuffle
df.cache() / persist()
Keeps a result around for repeated use
repartition vs coalesce
The first shuffles, the second only merges
Parquet / ORC
Columnar, compressed, with built-in stats for block skipping
Predicate Pushdown
Pushing filters down to the file-reading layer
Partition Pruning
Reading only the folders that match the filter
Delta / Iceberg / Hudi
A table layer with ACID guarantees on top of a data lake
Bronze / Silver / Gold
A layered architecture: raw, cleaned, ready to consume
Structured Streaming
Stream processing with the same DataFrame API
Watermark
Sets how much tolerance you give late-arriving data
Airflow DAG
Task orchestration as a directed acyclic graph
dbt model / test
Data transformation with versioned, testable SQL

Basics 8

What does "big data" actually mean, and where does it start?

▾Basic

The classic definition started with three properties, and two more were added later:

  • Volume — more than fits on one machine.
  • Velocity — a high, sometimes continuous, rate of incoming data.
  • Variety — structured, semi-structured and unstructured data together.
  • Veracity — uncertainty and variable data quality.
  • Value — what decision this volume of data actually produces.

A more practical and honest answer: big data starts where a single-machine tool stops working. With today's hardware, one server with 256GB of RAM and DuckDB or Polars can comfortably handle tens of millions of rows. Saying this in an interview shows you pick the tool for the problem, not for the résumé.

Explain Spark's execution architecture.

▾Basic
Driver ──► Cluster Manager (YARN / K8s / Standalone)
   │              │
   │              ├─► Executor 1 : Task, Task, Task
   │              ├─► Executor 2 : Task, Task
   └── DAG ───────┴─► Executor 3 : Task
  • Driver runs your code, builds the execution graph and schedules tasks.
  • Cluster Manager allocates resources.
  • Executor a process on a worker node that runs tasks and caches data.
  • Task the smallest unit of work; each partition is one task.
  • Stage a set of tasks between two shuffles.

If you see an OutOfMemory error on the driver, it usually means you pulled a large result to the driver with collect(). If it's on an executor, the culprit is usually an unbalanced partition or a heavy shuffle.

What's the difference between a transformation and an action in Spark?

▾Basic
df = spark.read.parquet("s3://lake/orders/")   # transformation — nothing has run yet
clean = (df.filter("status = 'paid'")          # transformation
           .withColumn("rev", col("qty") * col("price"))
           .groupBy("city").sum("rev"))        # transformation

clean.show(5)          # action — the whole chain now runs
clean.count()          # another action — runs from scratch again
clean.write.parquet("s3://lake/agg/")   # action

Lazy evaluation lets Spark see the whole chain and optimize it: merging filters, dropping unused columns and picking the best join type.

If you call several actions on one DataFrame without cache(), the entire computation reruns from scratch each time. This is the most common cause of unexpected slowness in Spark.

Why is Parquet preferred over CSV?

▾Basic
CSVParquet
LayoutRow-orientedColumnar
CompressionNoneHigh, per-column
Reading a few columnsWhole fileOnly those columns
Data typeNone, all textStored in the file
Built-in statsNoneMin/max per block
df.write.mode("overwrite").partitionBy("year", "month").parquet("s3://lake/orders/")
spark.read.parquet("s3://lake/orders/").filter("year = 2024 AND month = 8").select("city", "amount")

Three optimizations happen at once in the query above: partition pruning (only the August folder is read), column pruning (only two columns), and block skipping based on stats that rule out a block ahead of time. Together this is usually the difference between minutes and seconds.

What's the difference between a data lake, a data warehouse and a lakehouse?

▾Basic
Data LakeData WarehouseLakehouse
Data shapeRaw, any formatStructured and modeledOpen files with a table layer
SchemaOn readOn writeOn write, with evolution
CostLowHighMedium
TransactionsNoneYesYes (Delta, Iceberg)
ConsumerData engineer / scientistAnalyst / BIBoth

The risk of a data lake without governance is turning into a "data swamp": thousands of files with no catalog, no owner and no definitions. The lakehouse was built specifically to solve this.

How does MapReduce work, and why did Spark replace it?

▾Basic
Map:     each data block → (key, value) pairs
Shuffle: pairs are distributed to nodes by key
Reduce:  final aggregation per key

MapReduce's problem was writing the result of every stage to disk. For multi-stage, iterative algorithms, that repeated writing and reading became the bottleneck.

Spark kept the same model but holds intermediate results in memory and optimizes the whole chain before running it. For iterative work like model training, that difference can be tens of times faster.

How do you design file partitioning in a data lake?

▾Basic
s3://lake/silver/orders/
    year=2024/month=08/day=31/part-0000.parquet
    year=2024/month=08/day=31/part-0001.parquet
  • Partition on a column that's almost always in the filter — usually date.
  • Keep each partition between 128MB and 1GB.
  • Avoid partitioning on a high-cardinality column like user ID.
  • Two or three levels at most; more depth just adds overhead.
Partitioning on a column with thousands of unique values produces millions of tiny files and makes reads slower than not partitioning at all. This is the most common data lake design mistake.

What's the difference between object storage and HDFS?

▾Basic
HDFSObject Storage (S3 and similar)
ManagementYou run the cluster yourselfA managed service
ScaleLimited by hardwareEffectively unlimited
CostCapital expenseConsumption-based
Compute and storageShared nodesSeparate
Renaming a folderInstantFull copy, expensive

That last row has an important practical consequence: the "write to a temp path, then rename" pattern, which is cheap on HDFS, becomes very expensive on object storage. Table layers like Delta and Iceberg were built specifically to solve this with a transaction log file.

Intermediate 12

What's the difference between narrow and wide transformations, and why is a shuffle expensive?

▾Intermediate
  • Narrow: each output partition needs only one input partition. Like filter, select, withColumn. No data movement.
  • Wide: each output partition needs several input partitions. Like groupBy, join, distinct, orderBy.

In a shuffle, data is written to disk, transferred over the network, and read again. The cost is disk and network, not CPU — which is why it's usually the main bottleneck.

spark.conf.set("spark.sql.shuffle.partitions", 200)   # default; too many for small data
df.explain(True)          # see the Exchange boundaries in the plan

In the execution plan, every Exchange is a shuffle. Reducing the number of Exchanges has the biggest optimization impact — for example with a broadcast join or by reordering operations.

What is a broadcast join, and when do you use it?

▾Intermediate
from pyspark.sql.functions import broadcast

result = fact_sales.join(broadcast(dim_product), on="product_id", how="left")

# automatic threshold
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", 50 * 1024 * 1024)  # 50MB

The small table is copied in full to every executor, so the large table never needs a shuffle. For joining a Fact table with a Dimension, it's almost always the right choice.

If the "small" table turns out to actually be large, the driver runs out of memory collecting it. Set the threshold deliberately and check the table's real size before broadcasting.

How do you detect and fix data skew?

▾Intermediate

The symptom: out of a thousand tasks in a stage, 998 finish in seconds and two take half an hour. The cause: the join or group-by key has a very unbalanced distribution.

# detect
df.groupBy("customer_id").count().orderBy(col("count").desc()).show(10)

# fix 1: key salting
from pyspark.sql.functions import concat_ws, lit, rand, floor
salted = df.withColumn("skey", concat_ws("_", col("customer_id"), floor(rand() * 16)))

# fix 2: enable adaptive execution (Spark 3+)
spark.conf.set("spark.sql.adaptive.enabled", True)
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", True)

From Spark 3 onward, adaptive execution handles many skew cases automatically; turn it on first and only reach for manual salting if that's not enough. Knowing this order shows you've kept up with newer versions.

What's the difference between repartition and coalesce?

▾Intermediate
df.repartition(200)                  # full shuffle, balanced distribution
df.repartition("city")               # partition by key
df.coalesce(10)                      # only merges adjacent partitions, no shuffle

# a common pattern before writing, to avoid thousands of tiny files
(clean.repartition("year", "month")
      .write.partitionBy("year", "month")
      .mode("overwrite").parquet(path))

The "small files" problem is one of the most common data-lake headaches: thousands of files a few kilobytes each make later reads dramatically slower, because the overhead of opening and closing files outweighs the data itself. Setting the partition count before writing is the main way to prevent it.

Write a complete PySpark job.

▾Intermediate
from pyspark.sql import SparkSession, functions as F, Window

spark = (SparkSession.builder
         .appName("daily_city_revenue")
         .config("spark.sql.adaptive.enabled", "true")
         .getOrCreate())

orders = spark.read.parquet("s3://lake/bronze/orders/")
cust   = spark.read.parquet("s3://lake/bronze/customers/")

daily = (orders
    .filter((F.col("status") == "paid") & (F.col("order_date") >= "2024-01-01"))
    .join(F.broadcast(cust.select("customer_id", "city")), "customer_id", "left")
    .groupBy(F.to_date("order_date").alias("d"), "city")
    .agg(F.sum("amount").alias("revenue"),
         F.count("*").alias("orders"),
         F.countDistinct("customer_id").alias("buyers")))

w = Window.partitionBy("city").orderBy("d").rowsBetween(-6, 0)
final = daily.withColumn("revenue_ma7", F.avg("revenue").over(w))

(final.repartition("d")
      .write.mode("overwrite").partitionBy("d")
      .parquet("s3://lake/silver/daily_city_revenue/"))
+----------+------+-----------+------+------+-----------+ | d| city| revenue|orders|buyers|revenue_ma7| +----------+------+-----------+------+------+-----------+ |2024-08-30|Tehran| 9,100,000| 62| 58| 8,740,000| +----------+------+-----------+------+------+-----------+

Window functions in Spark mean exactly what they mean in SQL. If you have SQL experience, most of that knowledge transfers directly — and it's worth saying so in the interview.

What does the medallion architecture (Bronze, Silver, Gold) mean?

▾Intermediate
  • Bronze — raw data, exactly as it arrived, unchanged. Full, replayable history.
  • Silver — cleaned, typed, deduplicated, with standardized keys.
  • Gold — aggregated and modeled for final consumption: Fact and Dimension tables, or ready-made metrics.

The main benefit is that if the transformation logic was wrong, you don't need to pull from the source again; you reprocess from Bronze.

Never modify data in Bronze, even if it's obviously wrong — correction is Silver's job. Following this rule is the difference between a pipeline you can rely on and one you can never debug.

What does Airflow do, and how do you write a simple DAG?

▾Intermediate
from airflow import DAG
from airflow.operators.python import PythonOperator
from datetime import datetime, timedelta

default_args = {
    "owner": "data-team",
    "retries": 3,
    "retry_delay": timedelta(minutes=5),
    "email_on_failure": True,
}

with DAG("daily_sales_etl",
         start_date=datetime(2024, 1, 1),
         schedule="0 2 * * *",
         catchup=False,
         max_active_runs=1,
         default_args=default_args) as dag:

    extract   = PythonOperator(task_id="extract",   python_callable=run_extract)
    transform = PythonOperator(task_id="transform", python_callable=run_transform)
    validate  = PythonOperator(task_id="validate",  python_callable=run_checks)
    load      = PythonOperator(task_id="load",      python_callable=run_load)

    extract >> transform >> validate >> load

Two settings matter: catchup=False prevents a flood of backfilled runs when the DAG is first turned on, and max_active_runs=1 stops two instances writing to the same table at once. The validate step before load is deliberate too: bad data should never reach the consumption layer.

What problem does dbt solve?

▾Intermediate
-- models/marts/fct_sales.sql
{{ config(materialized='incremental', unique_key='order_id') }}

SELECT o.order_id, o.customer_id, o.order_date, o.amount, c.city
FROM {{ ref('stg_orders') }} o
LEFT JOIN {{ ref('stg_customers') }} c USING (customer_id)
WHERE o.status = 'paid'
{% if is_incremental() %}
  AND o.order_date > (SELECT MAX(order_date) FROM {{ this }})
{% endif %}
  • You write transformations in SQL and version them in Git.
  • ref automatically builds the dependency graph and decides run order.
  • Data tests (uniqueness, non-null, accepted values, referential integrity) are defined right next to the model.
  • Documentation and data lineage are generated automatically.

A good line for the interview: "dbt brings software-engineering best practices — versioning, testing, code review and documentation — into the data transformation layer."

Explain Spark's write modes.

▾Intermediate
df.write.mode("overwrite").parquet(path)   # wipe everything and start fresh
df.write.mode("append").parquet(path)      # add on top
df.write.mode("ignore").parquet(path)      # do nothing if it already exists
df.write.mode("error").parquet(path)       # default: raise an error

# overwrite only the partitions present in the DataFrame
spark.conf.set("spark.sql.sources.partitionOverwriteMode", "dynamic")
df.write.mode("overwrite").partitionBy("day").parquet(path)
overwrite mode with partitioning and without the dynamic setting wipes the entire path, not just the new partitions. This is one of the most common real-world ways to lose history.

What does a user-defined function (UDF) cost in Spark?

▾Intermediate
# slow: every row gets serialized between the JVM and Python
@udf("double")
def margin(rev, cost): return (rev - cost) / rev if rev else None

# fast: built-in functions, run entirely in the JVM
from pyspark.sql import functions as F
df = df.withColumn("margin", (F.col("rev") - F.col("cost")) / F.col("rev"))

# middle ground: pandas UDF with Arrow batch transfer
@F.pandas_udf("double")
def margin_v(rev: pd.Series, cost: pd.Series) -> pd.Series:
    return (rev - cost) / rev

The order of preference is clear: built-in functions first, pandas UDF if that's not possible, and a plain Python UDF only as a last resort. The speed difference on large data can be tenfold, because a plain UDF also blocks Catalyst's optimizations.

How do you tune Spark resource configuration?

▾Intermediate
spark-submit \
  --num-executors 10 \
  --executor-cores 4 \
  --executor-memory 16g \
  --driver-memory 8g \
  --conf spark.sql.shuffle.partitions=400 \
  --conf spark.sql.adaptive.enabled=true \
  --conf spark.serializer=org.apache.spark.serializer.KryoSerializer \
  job.py

Rules of thumb: 3 to 5 cores per executor (more causes I/O contention), shuffle partition count around two to three times the total cores, and only raise driver memory when you're actually collecting results.

Optimize the job's logic before touching these numbers. Throwing more resources at a job with an unbalanced shuffle is like adding a production line to a factory whose bottleneck is the warehouse.

What join execution types does Spark have?

▾Intermediate
TypeWhen it's chosenCost
Broadcast Hash JoinOne side is smallLowest, no shuffle
Sort Merge JoinBoth sides are largeShuffle and sort
Shuffle Hash JoinOne side is mediumShuffle, no sort
Broadcast Nested LoopJoin with no equality conditionVery expensive
df.join(other, "id").explain()   # see the chosen type in the plan

If you see BroadcastNestedLoopJoin in the plan, it usually means your join condition isn't equality (e.g. it's a range). In that case you need to redesign the problem — for example bucketing the values and turning the range condition into an equality on the bucket.

Advanced 10

What do Delta Lake or Iceberg add to a data lake?

▾Advanced
  • ACID transactions on top of Parquet files; readers never see a half-written state.
  • Time travel: reading a previous version of the table for review or rollback.
  • Schema evolution: adding a column without rewriting all the data.
  • Support for UPDATE, DELETE and MERGE, which aren't possible on raw Parquet.
  • Compaction of small files and automatic layout management.
from delta.tables import DeltaTable

dt = DeltaTable.forPath(spark, "s3://lake/silver/customers")
(dt.alias("t")
   .merge(updates.alias("s"), "t.customer_id = s.customer_id")
   .whenMatchedUpdateAll()
   .whenNotMatchedInsertAll()
   .execute())

spark.read.format("delta").option("versionAsOf", 12).load(path)   # time travel

The MERGE capability is exactly what makes SCD Type 2 and CDC processing possible on a lake. Without it, you'd have to rewrite the whole partition every time.

How does stream processing with Structured Streaming work?

▾Advanced
from pyspark.sql import functions as F

stream = (spark.readStream
    .format("kafka")
    .option("kafka.bootstrap.servers", "kafka:9092")
    .option("subscribe", "orders")
    .option("startingOffsets", "latest")
    .load()
    .select(F.from_json(F.col("value").cast("string"), schema).alias("d"))
    .select("d.*"))

agg = (stream
    .withWatermark("event_time", "10 minutes")
    .groupBy(F.window("event_time", "5 minutes"), "city")
    .agg(F.sum("amount").alias("revenue")))

(agg.writeStream
    .outputMode("append")
    .format("delta")
    .option("checkpointLocation", "s3://lake/_ckpt/orders_agg")
    .trigger(processingTime="1 minute")
    .start())

Watermark says how long you're willing to wait for late-arriving data. After that, the window closes and its state memory is freed. Without it, state grows without bound.

The checkpointLocation path is mandatory, and it's exactly what makes the "exactly once" guarantee possible: offsets and intermediate state are stored there so processing can resume from the same point after a failure.

A Spark job has become slow. How do you troubleshoot it?

▾Advanced
  1. Find the slow stage in the Spark UI and look at the task-time distribution. If a few tasks are far slower than the rest, it's skew.
  2. Check the shuffle read/write volume. A shuffle of several terabytes usually points to a bad join.
  3. Run df.explain(True) and reduce the number of Exchanges.
  4. Broadcast the small-table join.
  5. Push filters as early in the chain as possible, ideally into the read layer.
  6. Set the shuffle partition count to match the data volume, not the default.
  7. cache() an intermediate result if it's used more than once.
  8. Only at the end, check resources: executor count, memory and cores.

Order matters: adding resources is always the last resort. Something that goes from an hour to three minutes with a correct join fix only goes to half an hour by doubling the cluster — and that cost is permanent.

Batch or streaming? How do you decide?

▾Advanced
CriterionBatchStreaming
LatencyMinutes to hoursSeconds
ComplexityLowHigh
Maintenance costLowHigh
ReprocessingSimpleNeeds design work
Good fit forReporting, warehousing, modelingFraud detection, alerting, real-time recommendations

The right question isn't "how fast can we go" but "how much latency can the decision made with this data actually tolerate". If a report is read the next morning, streaming the pipeline only adds cost and complexity with no value at all.

Explain the CAP theorem and the types of NoSQL databases.

▾Advanced

In a distributed system, when a network partition happens you can only choose one of consistency or availability.

TypeExampleGood fit for
Key-valueRedis, DynamoDBCache, sessions, counters
DocumentMongoDBSemi-structured, variable data
Wide-columnCassandra, HBaseVery high write rates
GraphNeo4jMulti-hop relationships, fraud detection
Analytical columnarClickHouse, DruidReal-time analytics on high volume

A sharper point you can add: CAP is only about the moment of a network partition. The PACELC model is more complete, because it says there's a latency/consistency trade-off in normal operation too.

How do you guarantee data quality at large scale?

▾Advanced

Six control layers found in a mature pipeline:

  1. Schema contract: the data producer commits to a specific structure, and a breaking change is blocked.
  2. Ingest-time tests: key uniqueness, non-null for critical columns, allowed value ranges.
  3. Referential-integrity tests: every foreign key exists in the reference table.
  4. Volume-anomaly detection: today's row count compared against historical average.
  5. Reconciliation with a source of truth: e.g. comparing total revenue against the finance system.
  6. Freshness monitoring: alert when a table falls behind its expected schedule.
-- example dbt test
-- models/schema.yml
-- tests: unique, not_null, relationships, accepted_values

Item four saves you the most often: real breakages usually don't throw errors, they just quietly bring in less data. Alerting on row count catches this silent failure before your manager does.

Sketch a data architecture on a whiteboard, from source to dashboard.

▾Advanced
Sources           Ingestion           Storage             Transform        Consumption
─────────         ──────────         ────────────         ────────        ───────
Postgres  ──CDC──►  Kafka  ──────►   Bronze (Delta)  ──►  dbt / Spark ──► Gold
Files/API ──batch─► Airflow ─────►   Silver          ──►  Quality tests ──► Power BI
Web events ─stream─► Kafka   ─────►                                    ──► Metabase
                                                                      ──► ML model

Points worth mentioning while you draw:

  • Why CDC instead of periodic querying: less load on the operational database and every change is captured.
  • Where data stays immutable and where it gets rewritten.
  • Where orchestration and dependency management live.
  • Quality-control checkpoints and what happens on failure.
  • Where the semantic layer is, so everyone shares one definition of the metrics.

In this question, the interviewer isn't looking for the right boxes; they're checking whether you understand the trade-offs. For each choice, name the alternative and why you set it aside.

Compare the Lambda and Kappa architectures.

▾Advanced
  • Lambda: two parallel paths — batch for full accuracy and correctness, and fast for low latency; results are merged at the serving layer.
  • Kappa: a single streaming path only; historical reprocessing is done by replaying the same stream from the start.

Lambda's problem is maintaining two implementations of the same logic, which drift apart over time. Kappa's problem is that everything must be written as streaming, even work that's inherently batch.

In practice, today's dominant architecture is something in between: a table layer like Delta that accepts both streaming and batch writes and keeps a single version of the truth. Saying this shows you've followed the discussion up to date.

In data streaming, what's the difference between event time and processing time?

▾Advanced
  • Event time: the moment the event actually happened.
  • Ingestion time: the moment it entered the system.
  • Processing time: the moment it's processed.
agg = (stream.withWatermark("event_time", "15 minutes")
             .groupBy(F.window("event_time", "1 hour", "15 minutes"), "city")
             .agg(F.sum("amount")))

Business analysis should almost always be based on event time, otherwise a user who was in a tunnel and whose app synced later gets their sale attributed to the wrong day.

The sliding window above (one hour with a 15-minute step) is useful for continuous monitoring. But the larger the watermark, the more state memory is consumed — this accuracy/resource trade-off should be a deliberate choice.

How do you control data infrastructure costs?

▾Advanced
  1. Measure: attribute cost to team, pipeline and table so you know where the money goes.
  2. Reduce data scanned: partitioning, columnar format, column selection. Has the biggest impact.
  3. Storage lifecycle: move old data to a cheaper tier and delete after the retention period.
  4. Remove unused pipelines: tables no one has queried in months.
  5. Auto-suspending clusters and cheap capacity for non-critical work.
  6. Aggregate tables instead of ten dashboards repeatedly scanning the raw table.

Item four usually gives the biggest quick win with the least risk: in any organization, after two years a significant share of pipelines are running for reports nobody opens anymore.

ClickHouse

An analytical columnar database built for aggregating billions of rows. In teams that need real-time analytics on events, it replaces the operational database or a cloud warehouse. This section focuses on the table engine, the sort key, insert patterns and its differences from standard SQL.

ClickHouse cheat sheet

Concepts and commands you'll need constantlySQL
MergeTree
The main family of table engines
ORDER BY (a, b)
The sort key; the most important design decision
PARTITION BY toYYYYMM(d)
Partitioning; usually monthly
PRIMARY KEY
The sparse index, a prefix of the sort key
ReplacingMergeTree
Deduplicates by key, during merges
SummingMergeTree
Automatically sums numeric columns
AggregatingMergeTree
Keeps aggregate function state
Materialized View
An insert trigger; runs on write
LowCardinality(String)
Dictionary encoding for low-variety columns
uniqCombined(x)
Approximate, low-memory distinct count
quantile(0.95)(x)
Approximate percentile
argMax(a, b)
Value of a in the row where b is max
-If · -Array · -Merge
Combinator suffixes for aggregate functions
FINAL
Applies the merge at read time; slow
TTL d + INTERVAL 90 DAY
Automatically deletes or moves old data
SYSTEM query_log
Query history for optimization

Basics 6

What is ClickHouse, and when do you choose it?

▾Basic

A columnar, distributed database for analytical processing, optimized for aggregation speed.

  • Good fit: event analytics, logs, product metrics, real-time dashboards over billions of rows.
  • Poor fit: transactional load, frequent single-row updates and deletes, complex multi-table joins, needing full ACID guarantees.

A simple test: if your queries are "sum and count over millions of rows with a time filter," ClickHouse is excellent. If they're "read and update a single row by ID," you picked the wrong tool.

Build a MergeTree table and explain its parts.

▾Basic
CREATE TABLE events
(
    event_time   DateTime,
    event_date   Date DEFAULT toDate(event_time),
    user_id      UInt64,
    event_name   LowCardinality(String),
    city         LowCardinality(String),
    amount       Decimal(14, 2),
    props        Map(String, String)
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_name, user_id, event_time)
TTL event_date + INTERVAL 2 YEAR
SETTINGS index_granularity = 8192;
  • PARTITION BY is the unit of data management and deletion, not the unit of query optimization.
  • ORDER BY determines the physical order of the data and the primary index.
  • TTL automatically deletes old data or moves it to cheaper storage.

Monthly partitioning is usually right. Daily partitioning over several years of data creates thousands of partitions and causes slowness on its own — contrary to the intuition you bring from Postgres.

Why is ClickHouse so fast at aggregation?

▾Basic
  • Columnar storage: only the columns a query needs are read from disk.
  • High compression: like values sit next to each other, so compression ratios of 10x or more.
  • Vectorized processing: operations run on blocks of thousands of rows, not row by row.
  • Sparse index: instead of indexing every row, one marker every 8192 rows; the index fits in memory.
  • Parallelism across cores and nodes by default.

The key interview point: ClickHouse's index isn't a B-Tree that finds a row; it eliminates blocks that definitely can't contain a match. That's why column order in ORDER BY is decisive.

How do ClickHouse's data types differ from standard SQL?

▾Basic
UInt8, UInt32, UInt64, Int64        fixed-width integers
Float64, Decimal(18, 4)             floating point and exact decimal
String, FixedString(16)             unbounded-length string
LowCardinality(String)              for a low-variety column
Date, Date32, DateTime, DateTime64  date and time
Nullable(UInt32)                    allows NULL, at a cost
Array(String), Map(String, UInt32)  composite types
Enum8('paid'=1, 'cancelled'=2)      restricted values
Don't use Nullable unless you need to: it adds a hidden column to mark NULL, which both takes up space and slows things down. Often a default value like zero or an empty string is enough.

How do you load data into ClickHouse?

▾Basic
clickhouse-client --query "INSERT INTO events FORMAT CSVWithNames" < data.csv

# from a Parquet file
clickhouse-client --query \
  "INSERT INTO events SELECT * FROM file('data.parquet', Parquet)"
from clickhouse_connect import get_client
c = get_client(host="ch", database="analytics")
c.insert_df("events", df)                      # batch insert from pandas
print(c.query_df("SELECT count() FROM events"))
Never insert row by row. Every INSERT creates a new part on disk, and thousands of tiny inserts trigger a "too many parts" error that takes the cluster down. Send batches of at least a few thousand rows, or use a buffer.

Write a few basic analytical queries in ClickHouse.

▾Basic
-- fast count
SELECT count() FROM events;

-- daily aggregation
SELECT toDate(event_time) AS d,
       count()                       AS events,
       uniqCombined(user_id)         AS users,
       sum(amount)                   AS revenue,
       round(quantile(0.95)(amount)) AS p95
FROM events
WHERE event_date >= today() - 30
GROUP BY d
ORDER BY d;
d | events | users | revenue | p95 ------------+---------+--------+------------+-------- 2024-08-30 | 812,004 | 41,203 | 91,000,000 | 420,000 2024-08-31 | 903,551 | 44,880 | 97,400,000 | 438,000

count() is written without a star and reads from metadata, so it's nearly instant. uniqCombined is an approximate distinct count with roughly half a percent error, tens of times faster and far lighter on memory than count(distinct) at billion-row scale.

Intermediate 7

How do you choose the sort key?

▾Intermediate

The most important design decision. The rule: order columns from lowest to highest cardinality, and put the column that's always in the filter first.

-- bad: high-cardinality user_id first, filtering on event_name becomes useless
ORDER BY (user_id, event_time)

-- good: common filters go first
ORDER BY (event_name, city, user_id, event_time)
-- measure the effect: how many blocks were actually read
EXPLAIN indexes = 1
SELECT count() FROM events WHERE event_name = 'purchase' AND city = 'Tehran';

The index only works on a prefix of the key, exactly like a composite index in Postgres. Filtering on the third column without filtering on the first two eliminates essentially no blocks, and a full scan happens.

Compare the engines in the MergeTree family.

▾Intermediate
EngineWhat it doesUse case
MergeTreePlain sorted storageDefault for events
ReplacingMergeTreeKeeps the latest version per keyCDC consumption, latest state
SummingMergeTreeSums numeric columns sharing a keyPre-aggregated tables
AggregatingMergeTreeMerges aggregate function stateMaterialized views with uniq and quantile
CollapsingMergeTreeCancels a row with a sign markerCorrecting earlier records
Replicated*Any of the above, with replicationMulti-node production
Critical point: merging happens in the background at an unpredictable time, not at insert time. So right after inserting, you may still see duplicate rows. Either use FINAL, which is slow, or write the query so it aggregates on its own.

Get the latest state of each record without FINAL.

▾Intermediate
CREATE TABLE user_state
(
    user_id    UInt64,
    city       LowCardinality(String),
    segment    LowCardinality(String),
    updated_at DateTime
)
ENGINE = ReplacingMergeTree(updated_at)
ORDER BY user_id;

-- safe and fast approach: explicit aggregation instead of FINAL
SELECT user_id,
       argMax(city,    updated_at) AS city,
       argMax(segment, updated_at) AS segment,
       max(updated_at)             AS updated_at
FROM user_state
GROUP BY user_id;

argMax is ClickHouse's star function: it returns the value of one column from the row where another column is maximal — equivalent to Postgres's DISTINCT ON, but as an aggregate and fully parallelizable.

How does a Materialized View work in ClickHouse?

▾Intermediate

Unlike Postgres, this view isn't a periodic snapshot; it's an insert trigger that runs when the source table is written to and pushes the result into a target table.

CREATE TABLE daily_stats
(
    d          Date,
    event_name LowCardinality(String),
    events     UInt64,
    users      AggregateFunction(uniqCombined, UInt64),
    revenue    Decimal(18, 2)
)
ENGINE = AggregatingMergeTree
ORDER BY (d, event_name);

CREATE MATERIALIZED VIEW mv_daily TO daily_stats AS
SELECT toDate(event_time)          AS d,
       event_name,
       count()                     AS events,
       uniqCombinedState(user_id)  AS users,
       sum(amount)                 AS revenue
FROM events
GROUP BY d, event_name;

-- reading: the states need to be merged
SELECT d, sum(events) AS events, uniqCombinedMerge(users) AS users
FROM daily_stats WHERE d >= today() - 7
GROUP BY d ORDER BY d;
The view only runs on new data. To backfill history you have to manually run INSERT INTO daily_stats SELECT ... once. Forgetting this is the most common newcomer mistake.

Explain the combinator suffixes for aggregate functions.

▾Intermediate
SELECT
  countIf(amount > 1000000)                  AS big_orders,
  sumIf(amount, event_name = 'purchase')     AS revenue,
  avgIf(amount, city = 'Tehran')             AS tehran_aov,
  uniqCombinedIf(user_id, amount > 0)        AS paying_users,
  sumArray(quantities)                       AS total_items,
  quantilesIf(0.5, 0.9, 0.99)(amount, amount > 0) AS q
FROM events
WHERE event_date >= today() - 7;
big_orders | revenue | tehran_aov | paying_users | q -----------+-------------+------------+--------------+----------------------- 4,120 | 612,000,000 | 163,392 | 38,204 | [142000,420000,1180000]

The -If suffix is a much faster alternative to CASE WHEN inside an aggregate, and computes several conditional metrics in a single scan. Its equivalent in Postgres is the FILTER (WHERE ...) clause.

What limits does joining have in ClickHouse?

▾Intermediate
-- default: the entire right-hand table is loaded into memory
SELECT e.event_name, d.category, count()
FROM events e
LEFT JOIN dim_product d ON d.product_id = e.product_id
GROUP BY 1, 2;

-- better for a small dimension table: a dictionary
SELECT event_name,
       dictGet('dict_product', 'category', product_id) AS category,
       count()
FROM events GROUP BY 1, 2;
  • ClickHouse isn't optimized for heavy joins; the right-hand table needs to be small.
  • Table order matters: the large table goes on the left.
  • A Dictionary is a much faster alternative to joining with a dimension.

The design philosophy here differs from a data warehouse: instead of a star schema with lots of joins, flat, denormalized tables are preferred, because columnar compression largely offsets the cost of duplication.

Set up TTL and data lifecycle management.

▾Intermediate
ALTER TABLE events MODIFY TTL
    event_date + INTERVAL 90 DAY TO VOLUME 'cold',
    event_date + INTERVAL 2 YEAR DELETE;

-- automatically aggregate old data instead of a full delete
ALTER TABLE events MODIFY TTL
    event_date + INTERVAL 180 DAY
    GROUP BY event_date, event_name
    SET amount = sum(amount);

The second form is very useful in practice: after six months, row-level detail is dropped and only the daily aggregate is kept. Volume drops dramatically while the historical trend is preserved — without writing any pipeline.

Advanced 7

How do you troubleshoot a slow query in ClickHouse?

▾Advanced
-- 1) see how much data was actually read
SELECT query_duration_ms, read_rows, formatReadableSize(read_bytes) AS read,
       formatReadableSize(memory_usage) AS mem, query
FROM system.query_log
WHERE type = 'QueryFinish' AND event_time > now() - INTERVAL 1 HOUR
ORDER BY query_duration_ms DESC LIMIT 5;

-- 2) see how many blocks the index eliminated
EXPLAIN indexes = 1 SELECT ... ;

-- 3) detailed step-by-step profiling
SET send_logs_level = 'trace';

The fix path: filter on a prefix of the sort key, explicitly select only the columns you need, drop FINAL, replace a heavy join with a dictionary, and build a materialized view for frequent patterns.

The first number to check is read_rows. If a billion rows were read to produce a thousand rows of result, the problem is the sort key or the filter, not resources.

How are updates and deletes done in ClickHouse?

▾Advanced
-- mutation: asynchronous and heavy, rewrites the whole part
ALTER TABLE events DELETE WHERE user_id = 12345;
ALTER TABLE events UPDATE city = 'Karaj' WHERE user_id = 12345;

-- tracking status
SELECT * FROM system.mutations WHERE is_done = 0;

-- lightweight delete (newer versions)
DELETE FROM events WHERE user_id = 12345;

-- dropping an entire partition: the cheapest way
ALTER TABLE events DROP PARTITION '202301';
Mutations aren't built to run frequently; each one fully rewrites the affected parts. If your logic needs frequent updates, the right pattern is ReplacingMergeTree and inserting a new version, not UPDATE.

Write funnel and retention analysis using ClickHouse's special functions.

▾Advanced
-- funnel with a 24-hour window and enforced order
SELECT level, count() AS users
FROM (
  SELECT user_id,
         windowFunnel(86400)(
            event_time,
            event_name = 'view',
            event_name = 'add_to_cart',
            event_name = 'checkout',
            event_name = 'purchase'
         ) AS level
  FROM events
  WHERE event_date >= today() - 30
  GROUP BY user_id
)
GROUP BY level ORDER BY level DESC;

-- weekly retention
SELECT retention(
         event_date = '2024-08-01',
         event_date = '2024-08-08',
         event_date = '2024-08-15'
       ) AS r
FROM events GROUP BY user_id;
level | users ------+-------- 4 | 3,100 3 | 2,800 2 | 8,600 1 | 37,500

The windowFunnel function enforces order and a time window together — something that in standard SQL needs a complex query with several MIN(...) FILTER clauses. If you do product analytics, this function alone can be a reason to pick ClickHouse.

What does distributed architecture and replication look like in ClickHouse?

▾Advanced
-- local table on each node
CREATE TABLE events_local ON CLUSTER main (...)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/events', '{replica}')
ORDER BY (event_name, user_id, event_time);

-- distributed table: just one view over all the shards
CREATE TABLE events ON CLUSTER main AS events_local
ENGINE = Distributed(main, default, events_local, cityHash64(user_id));
  • Shard horizontal data split for scalability.
  • Replica an identical copy for fault tolerance and read-load distribution.
  • The distribution key needs to spread evenly; usually a hash of the user ID.

Choosing the distribution key matters: if your queries often group by user, hashing on user means aggregation happens locally on each shard and only a small result gets transferred.

Compare ClickHouse against data warehouses.

▾Advanced
ClickHouseCloud warehousePostgres
Query latencySub-secondSeconds to tens of secondsDepends on volume
Complex joinsWeakStrongStrong
UpdatesExpensiveMediumCheap
Cost at scaleLowHighMedium
ManagementSelf-managedManagedMedium
Best useReal-time event dashboardsEnterprise warehouseOperational and small-scale analysis

A common architecture pattern today: a cloud warehouse for enterprise modeling and financial reporting, and ClickHouse as a serving layer for dashboards that must respond in under a second with lots of users.

Connect ClickHouse to Kafka and Power BI.

▾Advanced
-- consume directly from Kafka, no intermediary service needed
CREATE TABLE kafka_events
(
    event_time DateTime, user_id UInt64,
    event_name String, amount Decimal(14,2)
)
ENGINE = Kafka
SETTINGS kafka_broker_list = 'kafka:9092',
         kafka_topic_list = 'sales.order.created.v1',
         kafka_group_name = 'ch-consumer',
         kafka_format = 'JSONEachRow';

-- automatic transfer into the main table
CREATE MATERIALIZED VIEW mv_kafka TO events AS
SELECT * FROM kafka_events;

For Power BI, the connection is made through the official ODBC driver or a ClickHouse connector. DirectQuery mode against ClickHouse is usually responsive, since it's built for low latency itself.

A table with the Kafka engine is just a queue, not storage; reading directly from it consumes the messages and they're no longer available afterward. Always route it through a materialized view into a MergeTree table.

What mistakes get expensive in ClickHouse?

▾Advanced
  1. Single-row inserts leading to a too-many-parts error.
  2. The wrong sort key, turning every query into a full scan.
  3. Habitual use of FINAL instead of explicit aggregation.
  4. Daily partitioning over several years of data.
  5. Unnecessary Nullable on columns that are never empty.
  6. Joining a large table with a large table instead of denormalizing or using a dictionary.
  7. Expecting transactional behavior; ClickHouse doesn't provide full ACID guarantees.

A good closing line: "ClickHouse is a fantastic tool as long as you don't treat it like Postgres. Most of the problems I've seen came from carrying row-store habits into the columnar world."

Apache Kafka

In data roles you're usually not responsible for administering the Kafka cluster; what matters is having the right mental model and being able to read and write data through it. This section focuses on topics, partitions, consumer groups and delivery guarantees.

Kafka cheat sheet

Terms and CLI commands you might get asked about.BASH
Topic
A named message channel; split into several partitions
Partition
An append-only, ordered log; the unit of parallelism
Offset
A message's sequence number within a partition
Producer
The writer of messages
Consumer Group
A group that reads a topic together; each partition goes to one member
Broker
A single Kafka server
Replication Factor
How many copies of each partition
ISR
In-Sync Replicas
Retention
How long/how much message data is kept, independent of being read
Log Compaction
Keeps only the latest value per key
kafka-topics.sh --list --bootstrap-server localhost:9092
List topics
kafka-topics.sh --describe --topic orders
Partitions, leader and ISR
kafka-topics.sh --create --topic t --partitions 6 --replication-factor 3
Create a topic
kafka-console-consumer.sh --topic t --from-beginning
Read for debugging
kafka-console-producer.sh --topic t
Send a message manually
kafka-consumer-groups.sh --describe --group etl-orders
See lag per partition
--reset-offsets --to-earliest --execute
Move the offset back to reprocess

Basics 10

K01

What is Kafka, and what problem does it solve?

▾Basic

A distributed, durable log for streams of events. Instead of every system connecting directly to every other system (which leads to N×M brittle connections), producers write to a topic and consumers read independently.

  • Producer and consumer are decoupled.
  • Messages aren't deleted after being read; they stay until retention ends and can be replayed.
  • Several independent consumers can read the same data for different purposes.

A concrete example: an "order placed" event is written once, and the warehouse system, the email system, and the analytics pipeline all read it at the same time.

K02

Explain Topic, Partition and Offset.

▾Basic
topic: orders
├── partition 0 : [0][1][2][3][4] ...
├── partition 1 : [0][1][2] ...
└── partition 2 : [0][1][2][3] ...
                   ^ offset

Message order is guaranteed only within a partition, not across the whole topic. If order matters for one entity (e.g. all the events of a single order), you need to key the message on order_id so it always goes to the same partition.

This point ("no global order, only per-key order") is the most common Kafka interview question.

K03

How does a Consumer Group work?

▾Basic
  • Each partition is assigned to only one consumer per group at any moment.
  • So maximum useful parallelism = number of partitions. With 3 partitions and 5 consumers, 2 sit idle.
  • Different groups are independent, each with its own offsets.
  • A member joining or leaving triggers a rebalance; processing briefly stops during it.

If asked "how do I increase throughput," the answer is: increase partition count first, then add consumers — not the other way around.

What path does a message take through Kafka?

▾Basic
Producer ──► Topic (partition 0,1,2) ──► Consumer Group
                    │
                    └─► stays on disk until retention ends
  1. The producer sends a message with an optional key to a topic.
  2. Kafka picks the target partition based on the key.
  3. The message is appended to that partition's log and written to disk.
  4. Consumers read in order from their own offset.
  5. The message isn't deleted after being read; it stays until the retention period ends.

That last property is the fundamental difference from a traditional queue: several independent consumers can read the same data, and if something goes wrong you can move the offset back and reprocess.

Write a simple producer in Python.

▾Basic
from kafka import KafkaProducer
import json

producer = KafkaProducer(
    bootstrap_servers=["kafka:9092"],
    key_serializer=lambda k: k.encode("utf-8"),
    value_serializer=lambda v: json.dumps(v, ensure_ascii=False).encode("utf-8"),
    acks="all",
    retries=5,
    linger_ms=20,          # wait a bit so messages get sent in batches
)

producer.send("orders", key="order-9001",
              value={"order_id": 9001, "amount": 850000, "city": "Tehran"})
producer.flush()

A small linger_ms significantly boosts throughput because messages get sent in batches. Forgetting flush() before the program exits is one of the most common causes of "my messages disappeared."

Inspect a topic from the command line.

▾Basic
kafka-topics.sh --bootstrap-server kafka:9092 --list
kafka-topics.sh --bootstrap-server kafka:9092 --describe --topic orders
kafka-console-consumer.sh --bootstrap-server kafka:9092 \
  --topic orders --from-beginning --max-messages 5 \
  --property print.key=true --property print.timestamp=true
Topic: orders PartitionCount: 6 ReplicationFactor: 3 Partition: 0 Leader: 1 Replicas: 1,2,3 Isr: 1,2,3 Partition: 1 Leader: 2 Replicas: 2,3,1 Isr: 2,3

In the --describe output, the first thing to check is whether Replicas and Isr match. If ISR is smaller, a replica has fallen behind and the cluster's fault tolerance is reduced.

What are the important settings for a consumer?

▾Basic
SettingMeaning
group_idGroup identifier; determines how partitions are split
auto_offset_resetWhen there's no stored offset: earliest or latest
enable_auto_commitAutomatic offset commits; better off for ETL
max_poll_recordsMaximum messages per fetch
max_poll_interval_msMaximum gap between two polls before being kicked from the group
session_timeout_msTimeout for detecting a dead member
If processing a batch takes long and exceeds max_poll_interval_ms, Kafka thinks the consumer is dead and triggers a rebalance. Result: endless duplicate processing. Either raise the interval or shrink the batch.

How do you bring Kafka up locally and test it?

▾Basic
# docker-compose.yml
services:
  kafka:
    image: bitnami/kafka:latest
    ports: ["9092:9092"]
    environment:
      KAFKA_CFG_NODE_ID: 0
      KAFKA_CFG_PROCESS_ROLES: controller,broker
      KAFKA_CFG_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
      KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 0@kafka:9093
docker compose up -d
kafka-topics.sh --bootstrap-server localhost:9092 --create --topic test --partitions 3
kafka-console-producer.sh --bootstrap-server localhost:9092 --topic test
kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic test --from-beginning

Newer Kafka versions no longer need ZooKeeper and use KRaft mode instead. If asked in an interview what ZooKeeper's role was, say it handled metadata management and leader election, and Kafka now does that itself.

How do you name and organize topics?

▾Basic
<domain>.<entity>.<event type>.<version>

sales.order.created.v1
sales.order.cancelled.v1
inventory.stock.changed.v1
sales.order.dlq.v1
  • Standardize naming from the start; changing it later is costly.
  • A version number in the name makes migrating to an incompatible schema possible.
  • Plan a dead-letter topic alongside every main topic too.

Another important decision: one topic per event type, or one topic for all of an entity's events? The second guarantees full ordering of one order's events and is usually preferred for CDC and event sourcing.

What effect do compression and batch size have on the producer?

▾Basic
producer = KafkaProducer(
    bootstrap_servers=["kafka:9092"],
    compression_type="lz4",   # gzip, snappy, lz4, zstd
    batch_size=32768,
    linger_ms=20,
    max_in_flight_requests_per_connection=5,
)
MethodCompression ratioSpeed
gzipHighSlow
snappyMediumFast
lz4MediumFastest
zstdHighGood

Compression is applied to the whole batch, so a bigger batch gives a better compression ratio. Combining a small linger_ms with compression usually both cuts bandwidth and raises throughput.

Intermediate 11

K04

Explain the three delivery guarantee levels.

▾Intermediate
LevelHowRisk
at-most-onceCommit first, then processPossible message loss
at-least-onceProcess first, then commitPossible duplicate processing
exactly-onceKafka transactions or idempotent consumptionMore complex and slower

A practical, mature answer: "I usually go with at-least-once and write the consumer to be idempotent — e.g. upsert on a unique key. In practice I get exactly-once behavior without the cost of distributed transactions."

K05

What is Consumer Lag, and how do you monitor it?

▾Intermediate
kafka-consumer-groups.sh --bootstrap-server kafka:9092 \
  --describe --group etl-orders
TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG CONSUMER-ID orders 0 182340 182355 15 c1-... orders 1 179002 204118 25116 c2-...

Lag is the gap between the consumer's current offset and the latest written offset. Growing lag means the consumer is falling behind production.

In the output above only partition 1 is behind — meaning the key distribution is unbalanced (a hot partition) or that consumer is slow. Spotting "asymmetric lag" is a good thing to point out.

K06

What's the difference between Retention and Log Compaction?

▾Intermediate
  • delete (default): messages are removed after a given time or size; suited to event streams.
  • compact: only the latest value per key is kept; suited to "latest state per entity," like a user profile table.
kafka-configs.sh --alter --entity-type topics --entity-name user_profile \
  --add-config cleanup.policy=compact,min.cleanable.dirty.ratio=0.1

A compacted topic is effectively a "table," and a regular topic is a "stream." This duality is called stream-table duality — a good term to use in an interview.

K07

What role does the message key play?

▾Intermediate
producer.send("orders", key=b"order-9001", value=payload)
# partition = hash(key) % num_partitions
  • Same key → always the same partition → order is preserved.
  • NULL key → round-robin distribution → maximum parallelism, no ordering guarantee.
  • Unbalanced key (e.g. 80% of traffic from one large customer) → a hot partition.
Increasing partition count after launch changes the hash mapping and breaks historical per-key order. So choose the partition count generously from the start.

What is a rebalance, and how do you reduce its impact?

▾Intermediate

Every time a member joins or leaves the group, partitions get redistributed and consumption pauses during that moment.

  • Common causes: a new deployment, slow processing exceeding the poll timeout, network instability.
  • Cooperative rebalancing in newer versions only moves the partitions that need to move instead of stopping everything.
  • Raise session_timeout_ms and tune max_poll_records correctly.
  • Use group.instance.id for static membership so redeployment doesn't trigger a rebalance.

Static membership is an underrated, useful point: in environments where containers restart often, it prevents constant back-to-back rebalances.

How do you manage offsets manually?

▾Intermediate
from kafka import KafkaConsumer, TopicPartition, OffsetAndMetadata

consumer = KafkaConsumer("orders", group_id="etl", enable_auto_commit=False,
                         bootstrap_servers=["kafka:9092"])

batch, offsets = [], {}
for msg in consumer:
    batch.append(msg.value)
    offsets[TopicPartition(msg.topic, msg.partition)] = OffsetAndMetadata(msg.offset + 1, None)
    if len(batch) >= 1000:
        write_to_db(batch)        # write first
        consumer.commit(offsets)  # then commit
        batch.clear(); offsets.clear()

Note that the committed offset must be one more than the last processed message, because the stored offset means "start here." An off-by-one error here causes the last message to be reprocessed forever.

How do you tune retention duration and topic size?

▾Intermediate
kafka-configs.sh --bootstrap-server kafka:9092 --alter \
  --entity-type topics --entity-name orders \
  --add-config retention.ms=604800000,retention.bytes=53687091200,segment.ms=86400000

kafka-configs.sh --bootstrap-server kafka:9092 --describe \
  --entity-type topics --entity-name orders
  • retention.ms retention duration in milliseconds; seven days here.
  • retention.bytes the size cap per partition.
  • segment.ms the interval for closing log files; cleanup only happens on closed segments.

Whichever of the two conditions — time or size — is hit first triggers cleanup. If you see data disappearing earlier than expected, the size cap is usually the culprit, not time.

How far does Kafka's ordering guarantee go?

▾Intermediate
  • Ordering is only guaranteed within a single partition.
  • Messages with the same key always go to the same partition, so per-key order is preserved.
  • Without a key, distribution is round-robin and there's no order at all.
  • Increasing partition count changes the key mapping and breaks historical order.
# preserve order even under retries
producer = KafkaProducer(enable_idempotence=True, acks="all", retries=10)
Without enable_idempotence, if several requests are in flight at once and one fails and gets resent, message order within that partition can get scrambled, or a duplicate can be recorded.

What do Kafka Streams and ksqlDB do?

▾Intermediate

A library and language for stream processing without needing a separate cluster; your application is both the consumer and the processor.

-- ksqlDB
CREATE STREAM orders (order_id BIGINT, customer_id INT, amount DOUBLE)
  WITH (KAFKA_TOPIC='sales.order.created.v1', VALUE_FORMAT='JSON');

CREATE TABLE revenue_per_customer AS
  SELECT customer_id, SUM(amount) AS total, COUNT(*) AS orders
  FROM orders WINDOW TUMBLING (SIZE 1 HOUR)
  GROUP BY customer_id
  EMIT CHANGES;

Know the key distinction in this world: a Stream is an endless sequence of events, and a Table is the latest state per key. Aggregating a Stream produces a Table, and recording a Table's changes produces a Stream again.

How many partitions should a topic have?

▾Intermediate

Decision criteria:

  • Target throughput divided by the throughput of a single partition.
  • Maximum consumption parallelism: the number of active consumers never exceeds the number of partitions.
  • Future growth: increasing it later breaks per-key ordering, so be generous.
  • Overhead: every partition takes files and memory on every broker; tens of thousands of partitions slow the cluster down.

A practical starting point for a typical topic: six to twelve partitions. If throughput or consumer count is known, calculate it; if not, pick a number several times the current consumer count.

How is security provided in Kafka?

▾Intermediate
  • Encryption in transit with TLS between clients and brokers, and between brokers.
  • Authentication with SASL (mechanisms like SCRAM or Kerberos) or TLS certificates.
  • Authorization with ACLs at the level of topic, consumer group and operation.
  • Message-level encryption for highly sensitive data, since Kafka itself doesn't encrypt the payload.
kafka-acls.sh --bootstrap-server kafka:9092 --add \
  --allow-principal User:etl-service \
  --operation Read --topic sales.order.created.v1 --group etl-orders

Follow the principle of least privilege: each service should only have access to its own topics, and only the operations it needs. A producer shouldn't have read permission, and a consumer shouldn't be able to write.

Advanced 9

K08

How do replication, ISR and acks determine durability?

▾Advanced
acksMeaningRisk
0Doesn't wait for any acknowledgmentFastest, high chance of loss
1Only the leader acknowledgesIf the leader dies before replicating, data is lost
allAll ISRs must acknowledgeSafest, slower
acks=all
min.insync.replicas=2
replication.factor=3
enable.idempotence=true

The combination above is the production standard: with 3 replicas and at least 2 in-sync, losing one broker causes no problem.

K09

What do Kafka Connect and Schema Registry do?

▾Advanced
  • Kafka Connect: a ready-made framework for moving data without writing code. A Source Connector reads from a source (e.g. Debezium for CDC from Postgres), and a Sink Connector writes to a destination (S3, Elasticsearch, a warehouse).
  • Schema Registry: holds message schemas (Avro/Protobuf/JSON Schema) and enforces compatibility. It stops a producer from dropping a field and breaking every consumer.

If schema change comes up, use the term backward / forward compatibility: adding a field with a default is usually compatible; removing a required field isn't.

K10

How does Kafka differ from a traditional message queue (RabbitMQ)?

▾Advanced
KafkaRabbitMQ
ModelDurable log, consumer has an offsetQueue; message deleted after ack
ReplayYesNo
ThroughputVery highModerate
RoutingSimple (topic/partition)Rich (exchange, routing key)
Good fit forEvent streams, analytics, logsTask queues, RPC
K11

Scenario: your consumer is 2 million messages behind. What do you do?

▾Advanced
  1. First diagnose: use --describe to see if the lag is on every partition or just one.
  2. If it's on all of them → the consumer is slow. Common causes: single-record database writes, calling an API inside the loop, or heavy processing.
  3. Short-term fix: batch the writes, raise max.poll.records, add consumers up to the partition count.
  4. If one partition is behind → unbalanced key distribution; the keying strategy needs redesigning.
  5. If the data no longer has real-time value and just needs catching up, historical consumption can be handed to a separate consumer group so it doesn't hold up the real-time path.

This is a scenario question; its value is in the "measure → find the root cause → fix" order, not in naming one specific setting.

How do you handle malformed messages?

▾Advanced
for msg in consumer:
    try:
        rec = json.loads(msg.value.decode("utf-8"))
        validate(rec)
        process(rec)
    except Exception as e:
        producer.send("orders.dlq", key=msg.key, value=json.dumps({
            "original": msg.value.decode("utf-8", "replace"),
            "error": str(e),
            "topic": msg.topic, "partition": msg.partition, "offset": msg.offset,
        }, ensure_ascii=False).encode())
        log.error("Malformed message at offset %s: %s", msg.offset, e)

Without a dead-letter queue (DLQ), one bad message stops the whole consumer, and thousands of good messages back up behind it — this is called a "poison message." With a DLQ, processing continues and the bad message is set aside for review.

Scenario: you need to reprocess the last three days. What do you do?

▾Advanced
# 1) stop the consumer, or the command will be rejected
# 2) move the offsets to a point in time
kafka-consumer-groups.sh --bootstrap-server kafka:9092 \
  --group etl-orders --topic orders \
  --reset-offsets --to-datetime 2024-08-28T00:00:00.000 --execute

# other options:
#   --to-earliest        from the start of retention
#   --shift-by -5000     five thousand messages back
#   --to-offset 182340   to a specific offset

Important considerations before running this:

  • Consumption must be idempotent, or duplicate records land at the destination.
  • If the real-time path mustn't be disrupted, do the reprocessing with a separate group_id.
  • Make sure three-day-old data is still within the retention window.

Definitely suggest the "separate group" solution: the production path stays untouched and reprocessing happens in parallel, safely.

What metrics does monitoring Kafka cluster health involve?

▾Advanced
MetricMeaningAlert when
Consumer lagConsumption falling behindA steady upward trend
Under-replicated partitionsReplicas that have fallen behindAny value above zero
Offline partitionsPartition without a leaderAny value above zero
Request latency p99Broker response latencyA sudden spike
Disk usageLog spaceAbove 75%
ISR shrink rateReplicas dropping out of syncFrequent recurrence

The second and third metrics are the most critical: any number above zero means the cluster's fault tolerance has dropped, and one more failure could lead to data loss.

How do you manage schema evolution in Kafka?

▾Advanced
Compatibility typeMeansAllowed change
BackwardA new consumer reads old dataRemoving an optional field, adding a field with a default
ForwardAn old consumer reads new dataAdding an optional field
FullBothOnly adding or removing a field that has a default
NoneNo checkingAnything, risking breaking consumers

For a breaking change, the right pattern is creating a new versioned topic and gradually migrating consumers, not altering the existing topic.

The default setting is usually Backward. If producers and consumers aren't updated at the same time — which they never are in a large organization — it's better to require Full.

How do transactions and the "exactly once" guarantee work in Kafka?

▾Advanced
producer = KafkaProducer(transactional_id="etl-orders-1", enable_idempotence=True)
producer.init_transactions()

producer.begin_transaction()
try:
    for rec in batch:
        producer.send("orders.enriched", value=rec)
    producer.send_offsets_to_transaction(offsets, "etl-orders")
    producer.commit_transaction()
except Exception:
    producer.abort_transaction()

The consumer needs to read with isolation_level="read_committed" so it doesn't see messages from an unfinished transaction.

This guarantee only holds within Kafka — that is, reading from one topic and writing to another. If the destination is an external database, Kafka's transaction doesn't cover it, and you have to make the write idempotent yourself. Be sure to make this distinction clear in an interview.

Metabase

Metabase is an open-source BI tool that sits on top of your database. Questions in this section are mostly at the user level and light modeling and dashboard design, rather than internal architecture.

Metabase cheat sheet

SQL
Question
A saved query (via GUI or raw SQL)
Dashboard
A collection of Questions on one page
Collection
A folder for organization and access control
Model
The semantic layer; a base query others are built on
Metric / Segment
A central definition of a recurring metric or filter
{{variable}}
A parameter in raw SQL
[[AND x = {{v}}]]
An optional block; dropped if the parameter is empty
{{#123-model-name}}
Referencing another Question inside SQL
Field Filter
A parameter bound to a column; gives a smart filter widget
Caching
Caching results for heavy, frequently-run queries
Subscription / Alert
Periodic report delivery, or an alert on a condition

Basics 12

M01

What is Metabase, and when do you use it instead of writing a manual report?

▾Basic

A self-service layer on top of a database: non-technical people can ask questions and see dashboards without SQL. It's useful when the same handful of weekly reports keep getting requested from the data team — with Metabase you build it once and the stakeholder checks it themselves.

A good line: "My goal is for the data team not to be the reporting bottleneck."

M02

Write a parameterized query in Metabase.

▾Basic
SELECT DATE_TRUNC('day', order_date)::date AS d,
       SUM(amount) AS revenue
FROM orders
WHERE status = 'paid'
  AND {{date_range}}                 -- Field Filter on order_date
  [[AND city = {{city}}]]            -- optional: dropped if left empty
GROUP BY 1 ORDER BY 1;

The double brackets [[ ]] create an optional block. A Field Filter (unlike a plain variable) provides a date/list-value widget and gives the user a better experience. Knowing this distinction shows you've actually worked with Metabase.

Build a simple question in Metabase without SQL.

▾Basic
  1. Click New, then Question.
  2. Pick the source table, e.g. Orders.
  3. In the Filter section, set the condition: status equals paid and date within the last 30 days.
  4. In Summarize, pick the aggregate function: Sum of Amount.
  5. In Group by, set the City field.
  6. Change the visualization type to a bar chart and save.
Tehran 183,000,000 Mashhad 58,000,000 Isfahan 41,000,000

The advantage of the graphical mode over raw SQL is that Metabase can recognize the fields and enable drill down; the user can click the Tehran bar and see the detail. Raw SQL limits this capability.

What is Metabase's architecture, and how does it connect to data?

▾Basic

Metabase is a Java application that sits on top of your database. It doesn't copy the data itself; at view time it generates a query and runs it against the source.

  • Database the connection to the source, ideally with a read-only user.
  • Application DB Metabase's own database, holding settings, questions and users.
  • Sync & Scan a periodic process that reads table structure and sample values.

In a production install, move the application database from the default H2 to Postgres. H2 is fine for testing, but in production it risks corruption and losing all your settings.

What's the difference between Question, Dashboard and Collection?

▾Basic
ElementWhat it isUse
QuestionA saved query with a visualization typeThe basic unit of everything
DashboardA layout of several Questions on one pageAnswers one topic
CollectionA folder for organization and access controlSeparating teams
ModelA base query others are built onA light semantic layer

Set up your Collection structure from the start around teams and access levels, not around report type. Restructuring after hundreds of questions have piled up is very painful.

What does the graphical mode (Notebook editor) offer?

▾Basic
  • Picking a table, filtering, summarizing and grouping without writing SQL.
  • Joining another table through defined relationships.
  • Custom columns with simple expressions.
  • Sorting and limiting row count.
  • The ability to convert the result to SQL for manual editing.
Data: Orders
 └ Filter: Status = paid  AND  Created At = Previous 30 days
 └ Summarize: Sum of Amount  by  Customer → City
 └ Sort: Sum of Amount desc
 └ Row limit: 20

The advantage of graphical mode over raw SQL is that the user can drill down into the result and reach the underlying rows. With raw SQL, that capability is lost — a good criterion for choosing between the two.

Visualization types in Metabase and how to pick the right one

▾Basic
QuestionSuitable visualization
A single key numberNumber or Trend
Trend over timeLine
Comparing categoriesBar or Row
Share of a totalPie, only with few slices
Geographic distributionMap
Two numeric variablesScatter
A two-dimensional matrixPivot table

The Trend visualization is underused but very useful: it shows the period's number alongside the percent change from the previous period — exactly what a manager wants.

How do you connect a dashboard filter to cards?

▾Basic
  1. In dashboard edit mode, add a Filter and choose its type (Time, Location, ID, Text).
  2. For each card, specify which column this filter connects to.
  3. For cards based on raw SQL, the filter must connect to a variable of type Field Filter.
  4. Set a default value and lock the filter if needed.
If a card isn't connected to the filter, it silently shows all the data and the user thinks the filter is applied. After building any dashboard, change the filter once and make sure every card responds.

How do sharing and periodic subscriptions work?

▾Basic
  • Dashboard subscription: periodically sending a dashboard to email or Slack on a chosen schedule.
  • Alert: sent only when the result meets a specific condition, e.g. when a number crosses a threshold.
  • Public link: a public link with no authentication.
  • Embedding: embedding in another application with a signed token.
Use public links carefully: anyone with the URL sees the data, and search engines may even index it. For embedding inside an internal system, signed Embedding is the right choice.

How do you install and set up Metabase?

▾Basic
docker run -d -p 3000:3000 --name metabase \
  -e MB_DB_TYPE=postgres \
  -e MB_DB_DBNAME=metabase -e MB_DB_PORT=5432 \
  -e MB_DB_USER=mb -e MB_DB_PASS=*** -e MB_DB_HOST=db \
  -e JAVA_TIMEZONE=Asia/Tehran \
  metabase/metabase
  • Set the application database to Postgres from the start, not the default H2.
  • Set the timezone, or the day boundary in reports will shift.
  • Put it behind a reverse proxy with HTTPS.
  • Tune JVM memory to match the number of users.

Migrating from H2 to Postgres after hundreds of questions have piled up is possible but painful. Ten minutes spent on day one saves hours later.

What options does summarizing and grouping have in graphical mode?

▾Basic
  • Aggregate functions: count, sum, average, min, max, standard deviation, distinct count, percentile.
  • Grouping by column, or by date with day, week, month, quarter granularity.
  • Automatic bucketing of numeric columns into equal ranges.
  • Grouping by a related table's column through a defined relationship.

There's also a "Custom Expression" option that builds a calculated column or metric with a syntax similar to Excel formulas — e.g. Sum([Amount]) / Distinct([Customer ID]). For many needs, this is enough and you don't need to reach for raw SQL.

How are relationships between tables defined in Metabase?

▾Basic

During sync, Metabase reads foreign keys defined in the database and builds relationships automatically. If there's no physical foreign key, you need to define it manually.

  1. Go to Admin, then Table Metadata.
  2. Pick the column and set its semantic type to Foreign Key.
  3. Specify the target table and column.

Until the relationship is defined, the user can't reach the related table's columns in graphical mode and is forced to write SQL. In warehouses that often lack physical foreign keys, this manual step is necessary.

Intermediate 12

M03

A dashboard is slow. What do you do?

▾Intermediate
  1. See which card is slow (Metabase shows each query's run time).
  2. Check the query in the database with EXPLAIN ANALYZE; the problem is usually on the database side, not Metabase.
  3. Instead of computing heavy aggregations live, build an aggregate table or Materialized View and point the dashboard at it.
  4. Enable caching for heavy cards with a suitable TTL.
  5. Reduce the number of cards per dashboard; each card is a separate query.
  6. Set a default time-range filter so the user doesn't unintentionally scan the entire history.

Be sure to mention item 6 — it's the simplest and most effective, and it's often forgotten.

M04

How do you make sure every dashboard shares one definition of "revenue"?

▾Intermediate

By building a Model (or Metric) that holds the official definition, with every other Question built on top of it. If the definition changes, it changes in one place.

-- Model: fct_paid_orders
SELECT o.order_id, o.customer_id, o.order_date, o.amount, c.city
FROM orders o JOIN customers c USING (customer_id)
WHERE o.status = 'paid' AND o.is_test = false;

The problem it solves: "why does the finance dashboard's number differ from the sales dashboard's?" — itself one of the most common BI interview questions. The answer: a single, documented definition at the semantic layer.

M05

How do you manage permissions?

▾Intermediate
  • Define user groups (finance, sales, product) and grant access at the Collection level.
  • For sensitive data, use a read-only database user with limited access — Metabase shouldn't connect with a superuser.
  • For row-level filtering (e.g. each salesperson only sees their own region), sandboxing is used.
A security point worth mentioning: raw SQL in Metabase has no column-level restriction; real control needs to live at the database permissions level, not just in the UI.

Explain the types of variables in Metabase's raw SQL.

▾Intermediate
-- plain variable: the value is substituted directly
WHERE status = {{status}}

-- Field Filter: bound to a column, generates a smart widget and a full condition
WHERE {{order_date}}          -- becomes AND date BETWEEN ... on its own

-- optional block: dropped entirely if the parameter is left empty
[[AND city = {{city}}]]

-- referencing another Question
FROM {{#42-paid-orders}} AS o

The key difference: a plain variable only takes one value and you have to write the condition yourself, but a Field Filter can supply several values, a date range or a dropdown list and generates the SQL condition itself. For a better user experience, always prefer a Field Filter.

Why and how do you set table metadata?

▾Intermediate

Under Admin, then Table Metadata, you can set for each column:

  • A friendly display name instead of the technical column name.
  • A description shown to the user while building a question.
  • Semantic type: price, percent, email, city, foreign key.
  • Display format: decimal places, currency unit, thousands separator.
  • Hiding technical columns from user view.

This is the least glamorous but highest-payoff thing you can do in Metabase: with correct naming and hiding system columns, a non-technical user can genuinely build their own questions. Without it, everyone comes back to the data team again.

How do you configure caching?

▾Intermediate
  • Global caching with a minimum run time: only cache queries that take longer than N seconds.
  • A TTL multiplier based on run duration, or a fixed TTL per database.
  • Overriding TTL at the level of a specific dashboard or question.
  • A manual refresh button for forcing an update.
Caching conflicts with live operational data. If a user makes a real-time decision from a dashboard, a long TTL can be dangerous. Always show the last-updated time on the dashboard so the user knows how fresh the number is.

What is a Model in Metabase, and how does it differ from a Question?

▾Intermediate

A Model is a base query that behaves as a "data source": users start from it instead of the raw table.

-- Model: fct_paid_orders
SELECT o.order_id, o.customer_id, o.order_date,
       o.amount - COALESCE(o.discount, 0) AS net_amount,
       c.city, c.segment
FROM orders o
JOIN customers c USING (customer_id)
WHERE o.status = 'paid' AND o.is_test = false;
  • A single definition of business logic, written once, in one place.
  • Metadata and friendly names can be defined for its columns.
  • Can be persisted (storing the result in a table) for extra speed.

A Model is Metabase's answer to "why do two dashboards show different numbers." If everyone starts from the same Model, the is_test = false filter never gets missed.

Explain managing users and groups.

▾Intermediate
  • Users belong to a Group; access is granted to the group, not the individual.
  • Data access is defined at the database, schema or table level.
  • Collection access is separate from data access, with a view or edit level.
  • In enterprise versions, connecting to LDAP or SAML and automatic group sync is possible.

Two default groups matter: All Users, which everyone belongs to, and Administrators. A common mistake is granting broad access to All Users and then trying to restrict it for a specific group afterward — access is additive and the restriction never takes effect.

How do you set up an Alert?

▾Intermediate
  • Goal-based alert: when a numeric result crosses a threshold.
  • Result-based alert: when the query returns at least one row — good for data quality checks.
  • Check frequency and delivery channel (email or Slack) are configurable.
  • The "only send once" option prevents repeated alerting.
-- data-quality alert query: returns zero rows under normal conditions
SELECT 'Orphaned key in fact_sales' AS issue, COUNT(*) AS n
FROM fact_sales f LEFT JOIN dim_customer d USING (customer_key)
WHERE d.customer_key IS NULL
HAVING COUNT(*) > 0;

This pattern is very effective: write a query that returns no rows when everything is healthy, and set an alert for "when there's a result." This gives you a data-quality monitoring system with no extra tooling.

How does Metabase give users drill down?

▾Intermediate
  • See these records: from an aggregate number to the underlying rows.
  • Break out by: splitting that same number by another dimension.
  • Zoom in: on a time chart, from month down to day.
  • Filter by this value: clicking a value and applying it as a filter.
  • Click behavior: manually configuring click behavior to go to another dashboard or an external URL.
All of this only works on questions built with graphical mode or a Model. If you write the question in raw SQL, Metabase doesn't understand the structure and drill capability is effectively lost — this is the strongest reason to prefer graphical mode.

Wire dashboards together with click behavior.

▾Intermediate

In edit mode, click Click behavior in the card settings and choose one of these:

  • Go to a dashboard and pass the clicked value as a filter for that dashboard.
  • Go to a question to see the detail.
  • Go to a custom destination with a parameterized URL, e.g. opening the customer's record in a CRM.
  • Update dashboard filter to filter the rest of the cards on the same page.
https://crm.company.ir/customers/{{customer_id}}

The third option builds a good bridge between reporting and action: the user sees a high-risk customer on the dashboard and opens their CRM record with one click. That detail turns a dashboard from a report into a working tool.

How do you version and migrate Metabase questions?

▾Intermediate
  • Serialization: exporting one instance's content to a file and loading it into another — for moving from staging to production.
  • API: creating and updating questions programmatically.
  • Regular backups of the application database, which is the only complete copy of everything.
java -jar metabase.jar export /backup/mb-export
java -jar metabase.jar import /backup/mb-export

Metabase doesn't have Git-style change history like dbt. If versioning matters to you, keep the core logic at a lower layer (a View or a dbt model) and treat Metabase as just the presentation layer.

Advanced 6

M06

What qualities does a good dashboard have?

▾Advanced
  • Starts from a specific business question, not "whatever data we have."
  • A few key numbers (KPIs) with period comparison at the top; detail and breakdowns lower down.
  • Every chart carries one message; its title states the finding, not just the column names.
  • Shared filters at the top, with a sensible default.
  • Every metric's definition is documented and visible.
  • At the end, "now what do I do?" should be clear — a dashboard that doesn't lead to a decision is a decorative report.

If asked whether you've been involved in dashboard design, describe a real example from your own work: what the question was, what metric you set up, and what decision came out of it.

How does sandboxing and row-level security work in Metabase?

▾Advanced

In the enterprise edition, a group can be given a restricted view in place of the main table:

  • Simple sandbox: an automatic filter based on a user attribute, e.g. only their own region's rows.
  • Custom sandbox: replacing the table with a parameterized Question that also drops sensitive columns.
  • User attributes come from SSO or are defined manually.
Sandboxing doesn't apply to raw SQL. If a group has permission to write raw SQL, they can effectively bypass the restriction. So restricted groups shouldn't have Native query access — this point scores well in a data security interview.

What challenges does Metabase have at enterprise scale?

▾Advanced
  • Question sprawl: after a year, thousands of ownerless, duplicate questions. Fix: a naming convention, an owner for every Collection, and periodic review.
  • Load on the database: every dashboard view means several queries. Fix: a dedicated reporting replica, aggregate tables and caching.
  • Number inconsistency: everyone writes their own logic. Fix: a central Model and metadata.
  • Access control: raw SQL has no column restriction. Fix: control at the database permissions level.
  • Backups: the application database holds every asset and needs regular backups.

If you have experience maintaining Metabase in an organization, tell this same list with a real example. The interviewer will immediately see you haven't just used it, you've maintained it.

What can you do with the Metabase API?

▾Advanced
import requests
BASE = "https://bi.company.ir/api"
s = requests.Session()
s.headers["X-API-KEY"] = API_KEY

# list all questions and find unused ones
cards = s.get(f"{BASE}/card").json()
stale = [c for c in cards if not c.get("last_used_at")]
print(len(cards), "questions |", len(stale), "unused")

# run a question and get the result as JSON
rows = s.post(f"{BASE}/card/42/query/json").json()

# create a user and add them to a group
s.post(f"{BASE}/user", json={"first_name":"Sara","email":"s@x.ir","group_ids":[5]})

A good practical use: a monthly script that lists questions unused in the last six months and emails the Collection owner. Without this, the question count keeps growing endlessly and finding the right report gets harder.

How is embedding a report in another system done?

▾Advanced
  • Public link: simplest, no authentication — only for fully public data.
  • Static embedding: with a signed token; your server sets the parameters and the user can't change them.
  • Interactive embedding: the full Metabase experience inside your app with integrated authentication; requires the commercial edition.
import jwt, time
payload = {"resource": {"dashboard": 12},
           "params": {"customer_id": current_user.id},   # locked server-side
           "exp": round(time.time()) + 600}
token = jwt.encode(payload, METABASE_SECRET_KEY, algorithm="HS256")
url = f"{SITE}/embed/dashboard/{token}#bordered=false&titled=false"
In static mode, the parameter must be locked inside the token, not the URL. If you put customer_id in the query string, any user can change it and see someone else's data.

Metabase or Power BI? What's your selection criteria?

▾Advanced
CriterionMetabase fits betterPower BI fits better
Data maturityA clean, ready warehouseNeeds modeling and complex calculations
AudienceSelf-service operational teamsExecutive and financial reporting
Team skillKnows SQLHas Excel and modeling experience
CostOpen source, no per-user licensePer-user licensing
Data volumeDepends on database capacityA strong in-memory engine
GovernanceLightweightFull, with RLS and a deployment cycle

In many organizations both are used together, and that's fine, as long as the boundary is clear. A common pattern: Power BI for official, executive reporting, Metabase for quick exploration by operational teams on the same warehouse.

Microsoft Dynamics 365

In organizations running Dynamics 365, data people are expected to know where the data lives and how to report on it without straining the operational system. This section focuses on the data model, data-export paths and connecting to Power BI, not application development.

Dynamics 365 cheat sheet

Terms and tables that keep coming backSQL
D365 Finance & Operations
The enterprise edition for finance, supply chain and manufacturing
D365 Business Central
The edition for small and mid-size businesses
D365 Customer Engagement
Sales, marketing and service; built on Dataverse
Dataverse
The shared data layer for Power Platform and CE apps
Legal Entity / DataAreaId
Company; nearly every table has this column
Data Entity
A unified view over several tables for integration
DMF
The Data Management Framework for bulk import/export
Entity Store / AggregateMeasurement
The built-in analytical warehouse for ready-made reports
BYOD
Exporting data to your own SQL database
Synapse Link / Data Lake export
Today's method for continuous export to a lake
Dual-write
Two-way sync between F&O and Dataverse
OData / Web API
Read and write access to entities
CUSTINVOICEJOUR / TRANS
Sales invoice header and lines
SALESTABLE / SALESLINE
Sales order and its items
INVENTTRANS
Inventory transactions
GENERALJOURNALACCOUNTENTRY
General ledger journal entries

Basics 5

What is Dynamics 365, and how do its editions differ?

▾Basic

A family of Microsoft enterprise applications with two main branches:

  • Finance & Operations (formerly called AX): enterprise resource planning for large companies. Finance, inventory, manufacturing, purchasing and sales.
  • Business Central (formerly NAV): the same areas but for smaller organizations, with faster implementation.
  • Customer Engagement: sales, marketing, customer service and field service. These sit on Dataverse.

The important distinction for an analyst: F&O's data model is SQL tables with historical, uppercase names, while Customer Engagement works on Dataverse with named entities. The reporting path for the two is completely different.

What is Dataverse, and how does it relate to Power Platform?

▾Basic

Dataverse is the shared data layer that Customer Engagement apps, Power Apps and Power Automate all run on. Data is stored as tables (formerly called entities), columns and relationships, and security, business logic and auditing are all enforced at that same layer.

  • Standard tables like Account, Contact, Opportunity already exist.
  • Custom tables are built the same way.
  • Access is available through the Web API with the OData protocol.
  • For analysis, both a direct Power BI connection and exporting to a data lake are options.

If asked how Dataverse differs from a regular database, say Dataverse isn't just storage: row-level security model, business rules, workflow and auditing are part of the platform itself.

What options do you have for reporting in D365?

▾Basic
ToolGood forLimitation
Ready-made SSRS reportsOperational documents like invoicesLow flexibility, specialized development
Financial ReporterFinancial statementsLedger only
Entity Store and analytical reportsDashboards inside the system itselfLimited customization
Power BI on a data lakeEnterprise analysis and combining with other sourcesRequires setting up data export
Direct ODataLow volume, quick samplingSlow and strains the operational system

A practical rule: the more analytical and heavy a report is, the further it should stay from the operational system. Running a heavy analytical query directly against the production database is both slow and can disrupt users' daily work.

Why do Legal Entity and the DataAreaId column matter?

▾Basic
SELECT DATAAREAID       AS company,
       COUNT(*)         AS invoices,
       SUM(INVOICEAMOUNT) AS total
FROM CUSTINVOICEJOUR
WHERE INVOICEDATE >= '2024-01-01'
GROUP BY DATAAREAID
ORDER BY total DESC;
company | invoices | total --------+----------+----------- IR01 | 8,420 | 1.82e+10 IR02 | 3,110 | 6.40e+09
Forgetting DATAAREAID in a filter or a join condition is the most common F&O reporting mistake. In a multi-company environment, joining two tables without this column attaches one company's orders to another company's customers, and the numbers become silently wrong.

What are the core Customer Engagement entities?

▾Basic
TableMeaning
AccountA customer company or organization
ContactA real person
LeadAn unqualified lead
OpportunityA sales opportunity with an amount and probability
Quote / Order / InvoiceQuote, order, invoice
CaseA support request
ActivityA call, email, meeting

The standard sales path is: a Lead gets qualified and converts into an Account, Contact and Opportunity. Funnel analysis is built exactly on this chain, so understanding the conversion path is essential.

Intermediate 8

What is a Data Entity, and how does it differ from a table?

▾Intermediate

A Data Entity is a logical view that turns several physical tables into one flat, understandable structure. For example, the customer entity combines CUSTTABLE, DIRPARTYTABLE and addresses into one.

  • Used for bulk import and export.
  • Accessible via OData.
  • Enforces business validation logic on insert.
  • The basis of BYOD and many integrations.

For analysis, entities are more convenient because complex joins are done for you; but they're slower than reading tables directly. For a large initial load, the table or lake export is better; for incremental sync, the entity.

How do you get data out of F&O to analyze in Power BI?

▾Intermediate

Three main paths, from oldest to newest:

  1. BYOD — entities are synced to your own Azure SQL database. You have full control over the schema, but maintaining it is on you, and incremental sync must be configured correctly.
  2. Export to Data Lake / Synapse Link — tables are continuously written to the lake as files. Today's recommended method for large-scale analysis.
  3. Direct OData in Power Query — fastest way to get started, but only suited to low volume.
let
    Source = OData.Feed("https://ORG.operations.dynamics.com/data/",
                        null, [Implementation = "2.0"]),
    Cust   = Source{[Name = "CustomersV3"]}[Data],
    Trim   = Table.SelectColumns(Cust,
                {"CustomerAccount", "OrganizationName", "SalesCurrencyCode",
                 "AddressCity", "dataAreaId"}),
    Filt   = Table.SelectRows(Trim, each [dataAreaId] = "IR01")
in
    Filt

Be sure to say that in OData mode you need to select only the needed columns early and write the filter so it gets pushed to the server; otherwise Power Query pulls the entire entity, which is both slow and puts load on the system.

What are the core tables of the sales cycle in F&O?

▾Intermediate
SALESTABLE          Sales order header
  └─ SALESLINE      Order lines
       └─ INVENTTRANS   Related inventory transaction
CUSTINVOICEJOUR     Sales invoice header
  └─ CUSTINVOICETRANS   Invoice lines
CUSTTABLE           Customer
  └─ DIRPARTYTABLE  Party information (name, address)
INVENTTABLE         Item
GENERALJOURNALACCOUNTENTRY   Ledger journal lines
SELECT j.INVOICEDATE::date AS d,
       c.ACCOUNTNUM,
       p.NAME              AS customer_name,
       SUM(t.LINEAMOUNT)   AS invoiced
FROM CUSTINVOICEJOUR j
JOIN CUSTINVOICETRANS t
  ON  t.INVOICEID   = j.INVOICEID
  AND t.INVOICEDATE = j.INVOICEDATE
  AND t.DATAAREAID  = j.DATAAREAID
JOIN CUSTTABLE c
  ON  c.ACCOUNTNUM  = j.INVOICEACCOUNT
  AND c.DATAAREAID  = j.DATAAREAID
JOIN DIRPARTYTABLE p ON p.RECID = c.PARTY
WHERE j.DATAAREAID = 'IR01'
  AND j.INVOICEDATE >= '2024-01-01'
GROUP BY 1, 2, 3
ORDER BY invoiced DESC;

Notice that the invoice join key is composite: the invoice number alone isn't unique, and date and company must also be in the condition. This kind of composite key is common in F&O, and rows get duplicated if it's overlooked.

What is dual-write, and what problem does it solve?

▾Intermediate

A mechanism that keeps shared data like customer, product and price in near-real-time, two-way sync between F&O and Dataverse. Without it, the sales team in the CRM and the finance team in the ERP see two different versions of the same customer.

  • Ready-made mappings exist for standard entities and can be customized.
  • If one side is unavailable, errors queue up and need monitoring.
  • For analysis, the same data from both sides usually also flows to the lake.

From an analyst's view, the important thing is knowing which system is the "source of truth" for each entity. If customers are created in the ERP and flow to the CRM, a customer-count report should be based on the ERP side.

How does D365's security model work?

▾Intermediate
User ──► Role ──► Duty ──► Privilege ──► Permission on a table or form
  • Role a job role, like sales representative.
  • Duty a work responsibility, like entering a sales order.
  • Privilege a fine-grained permission, like reading the order table.
  • On top of this, access can be restricted by company and, in some cases, by row.
This security is enforced at the application layer. When you export data to a lake or BYOD, none of these restrictions come along with it. Access control in reports must be rebuilt from scratch in Power BI with row-level security — be sure to raise this point.

What are Entity Store and AggregateMeasurement?

▾Intermediate

Entity Store is an analytical warehouse inside F&O itself, holding aggregated data for built-in reports and embedded Power BI. AggregateMeasurement is the star-schema definition on top of it: metrics and dimensions are specified and refreshed on a schedule.

  • Benefit: gives ready, fast reports without setting up separate infrastructure.
  • Limitation: low flexibility, hard to combine with sources outside D365, and customization needs development.

Today's recommended path for serious analysis is exporting to a lake and modeling in Power BI. Treat Entity Store as a quick solution for standard reports, not as an enterprise data warehouse.

Analyze a sales funnel from Dynamics data.

▾Intermediate
SELECT DATE_TRUNC('month', createdon)::date AS month,
       COUNT(*)                                                   AS opportunities,
       COUNT(*) FILTER (WHERE statecode = 1)                       AS won,
       COUNT(*) FILTER (WHERE statecode = 2)                       AS lost,
       ROUND(100.0 * COUNT(*) FILTER (WHERE statecode = 1)
             / NULLIF(COUNT(*) FILTER (WHERE statecode IN (1,2)), 0), 1) AS win_rate,
       SUM(estimatedvalue) FILTER (WHERE statecode = 0)            AS open_pipeline,
       ROUND(AVG(actualclosedate - createdon::date)
             FILTER (WHERE statecode = 1))                          AS avg_days_to_win
FROM opportunity
WHERE createdon >= '2024-01-01'
GROUP BY 1 ORDER BY 1;
month | opportunities | won | lost | win_rate | open_pipeline | avg_days_to_win -----------+---------------+-----+------+----------+---------------+---------------- 2024-06-01 | 182 | 41 | 88 | 31.8 | 4,120,000,000 | 38 2024-07-01 | 210 | 55 | 91 | 37.7 | 5,340,000,000 | 34

The statecode column in Dynamics is numeric (open, won, lost) and needs to be resolved with the option-set metadata table. In reporting, turn these codes into a status dimension with a readable label so the user never sees the raw number.

How do you resolve Option Set columns in a report?

▾Intermediate

Many Dynamics columns are plain integers, with their label stored elsewhere. Three approaches:

  • Use the StringMap view or the option-set metadata to join the label.
  • Build a static dimension table in the warehouse for each frequently-used Option Set.
  • In Power Query, join with a reference table kept in the same project.
CREATE TABLE dim_opportunity_state (statecode int PRIMARY KEY, label_fa text);
INSERT INTO dim_opportunity_state VALUES (0,'Open'), (1,'Won'), (2,'Lost');
Hard-coding these values across several different queries is a source of inconsistency. Define them once in a dimension table and use that everywhere — especially since custom values may get added later.

Advanced 7

Design a star schema for sales analysis from D365 data.

▾Advanced
                DimDate        DimCustomer
                    \              /
                     \            /
   DimProduct ──── FactSalesInvoice ──── DimCompany (Legal Entity)
                          |
                    DimSalesChannel

FactSalesInvoice: InvoiceDate, CustomerKey, ProductKey, CompanyKey,
                  Quantity, NetAmount, DiscountAmount, CostAmount, Margin

Build path:

  1. Raw tables from the lake sit in the Bronze layer, unchanged.
  2. In Silver, the invoice header and lines are joined with the composite key and names are changed to a readable form.
  3. Dimensions are built from reference tables, and customer becomes historized with an SCD Type 2 pattern.
  4. The Fact table is built at the grain of "one invoice line."
  5. A separate date table is built and flagged with the organization's financial calendar.

Two decisions worth explaining: the Fact table's grain (invoice line, not order), and keeping company as a dimension rather than building a separate model per company. The latter enables cross-company aggregate reporting.

A financial report from D365 doesn't match the finance system's report. How do you check it?

▾Advanced
  1. Company and period: are both reports on the same DATAAREAID and the same date range? The organization's financial calendar isn't the same as the Gregorian calendar.
  2. Document status: are draft, unapproved documents also being counted?
  3. Currency: is the amount in transaction currency or reporting currency? Which date's exchange rate is used?
  4. Tax and discount: which report shows the gross amount and which shows net?
  5. Returns and adjustments: are reversed documents subtracted?
  6. Data freshness: when was the lake last synced? It might be a few hours behind.
  7. Row duplication: a join that doesn't fully honor the composite key multiplies amounts.

In practice, the first and third items account for most of it. Suggested approach: instead of comparing grand totals, compare the number for one day and one company, then drill down to a specific invoice. Always find the discrepancy at the finest level, not from the top.

How do you optimize reporting on high data volumes in D365?

▾Advanced
  1. Never run a heavy analytical query directly against the operational database; use the lake or BYOD.
  2. In Power BI, choose Import mode and avoid DirectQuery on entities.
  3. Enable Incremental Refresh on transactional tables.
  4. Import only the needed columns; F&O tables have dozens of system columns that never get used.
  5. Drop high-cardinality system columns like RECID and RECVERSION; they inflate the model size for no reason.
  6. Do heavy aggregations at the transformation layer, not in DAX.
  7. Shift sync load to low-traffic hours.

Item five matters specifically for F&O: RECID is a large unique numeric identifier, and if it enters the model, columnar compression becomes effectively useless and the model size can multiply.

What patterns exist for integrating D365 with other systems?

▾Advanced
PatternGood forConsideration
OData / Web APILow volume, real-time interactionRequest rate limits
DMF with filesBulk import/exportAsynchronous, needs status monitoring
Business EventsReacting to an event, e.g. order confirmationNeeds a consumer like Event Grid
Dual-writeSyncing reference data with DataverseDebugging queue errors
Data Lake exportAnalysis and data warehousingOne-way, read-only

The selection rule: if the need is real-time and low-volume, go for the API; if bulk and scheduled, go for DMF; and if the goal is analysis, always go for the lake export. A common mistake is using the API to move millions of rows, which is both slow and puts strain on the operational system.

How is customization done in D365, and what effect does it have on reporting?

▾Advanced

In the cloud edition, the development model is based on extension, not rewriting core code: new tables and fields are added in a separate model so Microsoft's updates don't break anything.

  • Custom fields usually carry the company's prefix or suffix and sit in the main table or an extension table.
  • Custom entities are built for integration.
  • Business logic is added through events and the chain of command.
The important effect for an analyst: custom fields don't automatically appear in the lake or BYOD export and need to be added to the entity definition or the export table list. If a report needs a custom column, this is the first thing to check.

A data analyst who's new to a D365 project — where should they start?

▾Advanced
  1. Scope out the domain: which modules are in use? Finance only, or supply chain too?
  2. Understand the company structure: how many Legal Entities exist, and how is cross-company reporting defined?
  3. Find the data-export path: lake, BYOD, or neither? How fresh is the data?
  4. Sit with the key user of each module and watch the real process; table names mean nothing without understanding the process.
  5. Clarify the financial calendar and currencies.
  6. Build one small report and reconcile its number against the system's standard report to make sure your mental model is correct.

The last item is the most important — emphasize it in an interview: before building any dashboard, reconcile one simple, verifiable number against an official source. The organization's trust in your reports is built starting from that very first number.

What's the difference between reporting from Dataverse and from F&O?

▾Advanced
Dataverse (CE)Finance & Operations
Data modelNamed, relatively clean tablesHistorical tables with AX-era names
Analytical accessSynapse Link, direct Power BI connectionData Lake export, BYOD, Entity Store
KeyGUIDNumeric RECID + business keys
Multi-companyUsually one environmentDATAAREAID on almost every table
Typical volumeModerateVery high in transactional tables

If both are used in an organization, the main challenge is reconciling the customer between the two systems. Dual-write solves this at the operational level, but in the warehouse you still need one unified customer dimension with explicit mapping.

Linux

Full systems-administration mastery isn't required; what matters in day-to-day data work is being able to find a file on a server, read a log, run a script, and diagnose why a disk filled up.

Linux cheat sheet

BASH
ls -lah · cd · pwd
Navigate and list with readable sizes
cp -r · mv · rm -rf
Copy, move, recursive delete
find . -name "*.csv" -mtime -7
CSV files changed in the last 7 days
grep -rn "ERROR" logs/
Recursive search with line numbers
grep -c · grep -v · grep -i
Count / invert / case-insensitive
head -n 20 · tail -f app.log
Start of a file / follow a log live
wc -l file.csv
Count lines
cut -d',' -f1,3 · sort · uniq -c
Extract columns, sort, unique count
awk -F',' '{s+=$3} END {print s}'
Sum a column
sed 's/old/new/g' f > out
Text replacement
df -h · du -sh * | sort -h
Disk space / folder sizes
free -h · top · htop
Memory and live processes
ps aux | grep python
Find a process
kill -9 PID
Force terminate
chmod +x run.sh · chmod 644
Execute / read permission
chown user:group file
Change ownership
tar -czf a.tar.gz dir/ tar -xzf a.tar.gz
Compress and extract
scp f.csv user@host:/path
Transfer a file over SSH
nohup python etl.py & > out.log 2>&1
Run in background, logging stdout and stderr
crontab -e
Scheduling

Basics 9

X01

In a log folder, count errors broken down by type.

▾Basic
grep -h "ERROR" logs/*.log | awk '{print $4}' | sort | uniq -c | sort -rn | head
412 TimeoutError 118 ConnectionRefused 33 KeyError

Memorize the pattern sort | uniq -c | sort -rn — it's "GROUP BY on the command line" and you'll need it constantly.

X02

Find large files that have filled up the disk.

▾Basic
df -h                                   # which partition is full
du -sh /var/* 2>/dev/null | sort -h | tail -5
find /var/log -type f -size +500M -exec ls -lh {} \;
/dev/sda1 200G 191G 9.0G 96% / 2.1G /var/lib 48G /var/log

The right order: df first to see which partition is full, then du to find the culprit. If you run du directly, you might end up chasing the wrong partition.

X03

What does the permission chmod 755 mean?

▾Basic
  7        5        5
owner    group    other
rwx      r-x      r-x
4+2+1    4+0+1    4+0+1     (r=4, w=2, x=1)

755 for an executable script or a folder, 644 for a regular file, 600 for a file holding a secret or key. Be sure to mention that last one.

How is the Linux path structure organized?

▾Basic
pwd                 # where am I
cd /var/log         # absolute path
cd ../data          # relative path
cd ~                # user's home directory
ls -lah             # full listing with readable sizes and hidden files
/home/analyst drwxr-xr-x 4 analyst analyst 4.0K Aug 31 10:12 . -rw-r--r-- 1 analyst analyst 18M Aug 31 09:40 orders.csv

In ls -l output, the first character shows the type (d for directory, - for file), and the next nine characters are owner, group, and other permissions. The following columns are, in order, owner, group, size and modification date.

How do you view a file's content?

▾Basic
cat  config.yaml           # the whole file
head -n 20 orders.csv      # first 20 lines
tail -n 50 app.log         # last 50 lines
tail -f app.log            # follow the file live as it's written
less big.csv               # page-by-page browsing, quit with q
wc -l orders.csv           # line count
50001 orders.csv
Never run cat on a multi-gigabyte file; it floods the terminal and can even lock up the SSH session. For a large file, always use head, tail or less.

How do you view and set environment variables?

▾Basic
env | sort | head            # all variables
echo $HOME $PATH
export DB_DSN="postgresql://user@host/db"    # this session only
echo 'export DB_DSN="..."' >> ~/.bashrc      # permanent for the user
unset DB_DSN

The difference between export and a plain assignment matters: without export, the variable isn't passed to child processes (like a Python script you run), and your program won't find it.

Edit a file with sed.

▾Basic
sed 's/old/new/'      f.txt      # only the first match per line
sed 's/old/new/g'     f.txt      # all matches
sed -i.bak 's/old/new/g' f.txt   # in-place edit with a backup
sed -n '10,20p'       f.txt      # only lines 10 to 20
sed '/^#/d'           conf.ini   # remove comment lines
sed 's/\r$//'         win.csv    # strip the Windows carriage return

Remember the last command: CSV files produced on Windows carry an extra \r character that sticks to the last column on Linux and causes strange type-conversion errors.

Transfer files between servers.

▾Basic
scp report.csv user@host:/data/incoming/
scp -r user@host:/var/log/app ./logs          # the whole folder
rsync -avz --progress ./data/ user@host:/data/  # resume an unfinished transfer
rsync -avz --dry-run ./data/ user@host:/data/   # preview only, no transfer

For a large or repeated transfer, rsync is always better than scp: it only sends the differences, resumes an interrupted transfer, and with --dry-run you can preview the result beforehand.

Disk space has filled up but you can't find a large file. What do you do?

▾Basic
df -h                       # which partition
df -i                       # have inodes run out?
du -sh /* 2>/dev/null | sort -h | tail
lsof +L1 | head             # deleted files that are still held open
/dev/sda1 200G 199G 0 100% / Filesystem Inodes IUsed IFree IUse% /dev/sda1 13M 13M 0 100% /

Two cases beginners get stuck on: running out of inodes rather than space (millions of tiny files), and a file that's been rm'd but is still held open by a process, so its space never got freed. The fix for the second is restarting that service.

Intermediate 7

X04

Schedule a Python script to run nightly.

▾Intermediate
crontab -e
# minute hour day month weekday
0 2 * * *   /opt/app/.venv/bin/python /opt/app/etl.py >> /var/log/etl.log 2>&1
*/15 * * * * /opt/app/health.sh          # every 15 minutes
0 6 * * 1   /opt/app/weekly.sh           # Mondays at 6 AM
Two common mistakes: (1) using python instead of the full path to the virtual environment's interpreter, because cron doesn't have the user's PATH. (2) not redirecting stderr with 2>&1, which makes errors disappear silently.
X05

Analyze a large CSV with command-line tools.

▾Intermediate
wc -l orders.csv                                   # row count
head -1 orders.csv | tr ',' '\n' | nl              # column names and numbers
awk -F',' 'NR>1 {s+=$4} END {printf "%.0f\n", s}' orders.csv   # sum column 4
awk -F',' 'NR>1 {a[$3]+=$4} END {for (k in a) print k, a[k]}' orders.csv | sort -k2 -rn | head
50001 orders.csv 1 order_id 2 customer_id 3 city 4 amount 183000000 Tehran 183000000 Mashhad 58000000

That last command is a full GROUP BY done in awk. If you can write this, it looks very good for a data role.

X06

A running process has high CPU usage. What do you do?

▾Intermediate
top -o %CPU                       # or htop
ps aux --sort=-%cpu | head -5
ls -l /proc/PID/cwd               # where the process was launched from
cat /proc/PID/cmdline | tr '\0' ' '
kill -15 PID                      # graceful termination first
kill -9  PID                      # only if it didn't respond

Explain the difference between -15 (SIGTERM, a chance to clean up and close connections) and -9 (SIGKILL, an immediate kill with no cleanup). For a pipeline that's writing to a database, -9 can leave data incomplete.

Build a report from a log file with awk.

▾Intermediate
# average response time by route, from a web log
awk '{ sum[$7] += $NF; cnt[$7]++ }
     END { for (p in sum) printf "%-30s %8.1f ms  %6d\n", p, sum[p]/cnt[p], cnt[p] }' \
     access.log | sort -k2 -rn | head
/api/orders 412.8 ms 18420 /api/reports 98.4 ms 2310 /health 2.1 ms 51200

Key awk variables: $1 through $n are fields, $NF is the last field, NR is the row number and -F sets the delimiter. awk's associative array is effectively a GROUP BY on the command line.

Write a reliable shell script.

▾Intermediate
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

RUN_DATE="${1:-$(date -d yesterday +%F)}"
LOG="/var/log/etl/${RUN_DATE}.log"
LOCK="/tmp/etl.lock"

exec 200>"$LOCK"
flock -n 200 || { echo "Previous run hasn't finished yet"; exit 1; }

cleanup() { echo "Finished with code $?"; }
trap cleanup EXIT

echo "Starting for $RUN_DATE" | tee -a "$LOG"
/opt/app/.venv/bin/python /opt/app/etl.py --date "$RUN_DATE" >>"$LOG" 2>&1
echo "Succeeded" | tee -a "$LOG"

Three important safeguards: set -euo pipefail, which stops the script at the first error; a file lock with flock, which prevents two instances running at once; and trap for cleanup. All three are essential in production.

Manage services with systemd.

▾Intermediate
systemctl status postgresql
systemctl restart my-etl.service
systemctl enable my-etl.service        # start automatically after boot
journalctl -u my-etl.service -n 100 --no-pager
journalctl -u my-etl.service --since "1 hour ago" -f

On modern servers, journalctl has replaced hunting through /var/log. The -f switch is the equivalent of tail -f and is the best tool for watching a service's errors live.

Analyze memory usage and system load.

▾Intermediate
free -h
uptime                                  # 1, 5 and 15-minute load average
nproc                                   # number of cores
ps aux --sort=-%mem | head -5
vmstat 1 5                              # CPU, memory and I/O activity
total used free shared buff/cache available Mem: 31Gi 18Gi 1.2Gi 0.3Gi 11Gi 12Gi 10:14:02 up 42 days, load average: 6.12, 5.80, 4.90

Two common misunderstandings: a low free column isn't a problem; the available column matters because cache is reclaimable. And you should divide load average by core count: a load of 6 is normal on an eight-core server, critical on a two-core one.

Advanced 4

X07

Explain pipes, redirects and exit codes.

▾Advanced
cmd1 | cmd2            # cmd1's output is cmd2's input
cmd > out.txt          # overwrite stdout
cmd >> out.txt         # append
cmd 2> err.txt         # stderr only
cmd > all.txt 2>&1     # both into one file
cmd1 && cmd2           # cmd2 only if cmd1 succeeded
cmd1 || cmd2           # cmd2 only if cmd1 failed
echo $?                # exit code of the last command; zero means success
#!/bin/bash
set -euo pipefail      # stop on first error, error on undefined variable, error inside a pipe
python etl.py && echo "OK" || { echo "FAILED"; exit 1; }

set -euo pipefail is the first line of every production script. Without it, the script happily continues after an error and corrupts data.

X08

Name the basic Docker commands needed for a data pipeline.

▾Advanced
docker ps -a                            # containers
docker logs -f --tail 100 my_etl        # live logs
docker exec -it my_db psql -U postgres  # enter a container
docker compose up -d && docker compose down
docker stats                            # resource usage
# a lightweight Dockerfile for a Python job
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/
CMD ["python", "-m", "src.main"]

The COPY order matters: requirements first, then code, so the package-install layer gets cached and a code change doesn't force a reinstall. Mentioning this shows you haven't just copy-pasted Docker.

Troubleshoot a network and database-connection problem.

▾Advanced
ping -c 3 db-host
nc -zv db-host 5432                    # is the port open
ss -tulpn | grep 5432                  # what's listening on this port
curl -v https://api.example.com/health
dig +short api.example.com             # is it a DNS issue?
traceroute db-host

Work through the troubleshooting order from the bottom up: does the domain name resolve? Is the host reachable? Is the port open? Does the service respond? Is authentication correct? Jumping straight to the application layer wastes a lot of time.

A Python script on a server has become slow. How do you investigate?

▾Advanced
top -p $(pgrep -d, -f etl.py)          # that process's live usage
cat /proc/$(pgrep -f etl.py)/status | grep -E 'VmRSS|Threads'
ls -l /proc/$(pgrep -f etl.py)/fd | wc -l     # number of open files
py-spy dump  --pid $(pgrep -f etl.py)         # where the code is stuck
py-spy top   --pid $(pgrep -f etl.py)
strace -p $(pgrep -f etl.py) -c -f            # system calls
Thread 0x7f2 (active): "MainThread" fetch_page (etl.py:88) run (etl.py:140)

py-spy is the best option because, without changing any code or stopping the process, it tells you which line is running right now. If the process is stuck on a network call, it becomes obvious immediately.

Git

Git questions are usually short, but a vague answer to them is taken as a sign of weak teamwork skills. The difference between merge and rebase, and how you undo mistakes, matter more than anything else here.

Git cheat sheet

BASH
git status · git log --oneline --graph
Status and compact history
git add -p
Add changes hunk by hunk — cleaner commits
git commit -m "msg"
Commit
git commit --amend
Fix the last commit
git switch -c feature/x
Create and switch to a branch
git merge main
Merge, creating a merge commit
git rebase main
Replay commits on top of main's tip
git stash · git stash pop
Temporarily set changes aside
git cherry-pick abc123
Pick up one specific commit
git restore file.py
Revert a file to the unstaged version
git restore --staged file.py
Unstage a file
git reset --soft HEAD~1
Undo a commit, keep changes staged
git reset --hard HEAD~1
Undo a commit and discard changes — dangerous
git revert abc123
A new commit that cancels a previous one
git reflog
HEAD's history; a lifesaver for a bad reset
git diff · git diff --staged
Compare changes
git blame file.py
Who wrote which line
git bisect start
Find the bad commit via binary search

Basics 11

G01

What's the difference between the working directory, staging area, and repository?

▾Basic
working dir  --git add-->  staging  --git commit-->  local repo  --git push-->  remote

The benefit of staging: you can put only part of your changes into one commit. With git add -p you can even choose line by line.

G02

What's the difference between git fetch and git pull?

▾Basic
git fetch origin          # only downloads, local branch untouched
git log HEAD..origin/main # see what came in
git merge origin/main     # now merge deliberately

git pull                  # = fetch + merge
git pull --rebase         # = fetch + rebase, a more linear history

A good answer: "I usually fetch and look at the changes first, then merge — a blind pull sometimes creates an unwanted merge commit."

G03

You committed a file by mistake. How do you undo it?

▾Basic
# haven't pushed yet:
git reset --soft HEAD~1        # commit undone, changes stay staged
git restore --staged secrets.env
echo "secrets.env" >> .gitignore

# already pushed:
git revert abc123              # a new commit that cancels the previous one
git push
The golden rule: don't rewrite shared, pushed history with reset --hard and a force push. If a sensitive file (a secret, a token) has been pushed, deleting it isn't enough — you need to revoke and reissue that credential.

Write out a day-to-day Git workflow, step by step.

▾Basic
git switch -c feature/add-churn-report   # a new branch off main
# ... make your changes ...
git status                               # what's changed
git diff                                 # exactly what changed
git add src/churn.py                     # stage it
git commit -m "feat: add churn cohort report"
git push -u origin feature/add-churn-report
# then open a Pull Request
On branch feature/add-churn-report Changes not staged for commit: modified: src/churn.py

Good habit: run git diff --staged once before every commit and see exactly what's being recorded. This alone prevents most accidental commits, like a local config file or leftover debug code.

Explain repository, branch and commit simply.

▾Basic
  • Repository: the project folder together with its full change history.
  • commit: a snapshot of the project's state with a descriptive message and unique ID.
  • Branch: a parallel line of development; actually just a pointer to a commit.
  • HEAD: a pointer to where you currently are.
  • remote: the shared version on a server, usually named origin.
git log --oneline --graph --all -n 5
* a1b2c3d (HEAD -> feature/x) add retry logic * 9f8e7d6 fix null handling | * 5c4b3a2 (origin/main) update readme |/ * 2b1a0c9 initial commit

A branch in Git is very lightweight — just a forty-byte file holding a commit ID. That's why creating a branch for every small change costs nothing and is encouraged.

Create a new repository and connect it to a server.

▾Basic
git init
git branch -M main
printf ".venv/\n__pycache__/\n*.csv\n.env\n" > .gitignore
git add .
git commit -m "chore: initial project structure"
git remote add origin git@github.com:user/repo.git
git push -u origin main

Creating .gitignore before the first add matters. If a file is committed once, adding it to gitignore afterward doesn't stop tracking it — it needs to be removed from the index with git rm --cached.

What's the difference between git rm --cached and git rm?

▾Basic
git rm --cached secrets.env    # untracked, the file stays on disk
git rm data.csv                # removed from tracking and from disk
git rm -r --cached data/       # the whole folder untracked
If you untrack a sensitive file with --cached, it's still in earlier history and anyone can see it with git log. Actually removing it from history needs a rewrite, and more importantly, that credential is no longer safe and must be revoked.

Temporarily set aside unfinished changes.

▾Basic
git stash push -m "wip: churn query"
git stash list
git stash show -p stash@{0}      # see the stash content
git stash pop                    # restore and remove from the list
git stash apply stash@{1}        # restore without removing
git stash push -u                # include untracked files

Remember the -u switch: by default, new, untracked files aren't included in the stash and are left stranded after switching branches, causing confusion.

Do the initial Git configuration.

▾Basic
git config --global user.name  "Milad Shabani"
git config --global user.email "me@example.ir"
git config --global init.defaultBranch main
git config --global pull.rebase true
git config --global core.autocrlf input       # Linux and Mac
git config --global alias.lg "log --oneline --graph --decorate --all"
git config --list --show-origin

The core.autocrlf setting matters in mixed Windows/Linux teams: without it, every time a file crosses between the two systems, Git sees the entire file as changed and diffs become useless.

What should a .gitignore for a data project contain?

▾Basic
# environment and secrets
.env
.venv/
*.pem

# data
data/
*.csv
*.parquet
*.xlsx
*.db

# python
__pycache__/
*.pyc
.ipynb_checkpoints/

# tools
.dbt/
target/
logs/
.DS_Store

If a file is already tracked, adding it to this list isn't enough: git rm --cached path is needed. To check what's being ignored, git check-ignore -v file tells you which rule caught it.

How do you review changes before committing?

▾Basic
git status -s                  # a summary of status
git diff                       # unstaged changes
git diff --staged              # what's going into the commit
git diff --stat                # just the line count per file
git add -p                     # add hunk by hunk, reviewing each piece
M src/transform.py ?? notebooks/scratch.ipynb src/transform.py | 24 +++++++++++++++---------

git add -p is a very good habit: it forces you to look at every change before recording it. The result is both cleaner commits and catching leftover debug code you forgot to remove.

Intermediate 10

G04

merge or rebase? Which, and when?

▾Intermediate
mergerebase
HistoryReal, branchingLinear and clean
New commitCreates a merge commitCommits are rewritten
Safe on a shared branchYesNo
git switch feature/x
git fetch origin
git rebase origin/main        # update your personal branch
# resolve conflicts, then:
git rebase --continue

The rule: rebase on a personal, unpushed branch, merge when bringing it into main. Be sure to say "I never rebase a branch that others are working on."

G05

How do you resolve a merge conflict?

▾Intermediate
<<<<<<< HEAD
threshold = 100
=======
threshold = 250
>>>>>>> feature/tuning
git status                 # affected files
# edit the file, remove the markers, keep the correct version
git add config.py
git commit                 # or git rebase --continue
git merge --abort          # if you want to cancel everything

Important point: always run the tests after resolving a conflict. Files where both versions look valid (like config) carry the most risk.

G06

Explain a branching strategy.

▾Intermediate
  • Trunk-based: short-lived branches off main, daily merges, feature flags for unfinished work. Best for small teams with frequent deploys.
  • GitHub Flow: branch off main, open a PR, merge after review. Simple and widely used.
  • Git Flow: develop/release/hotfix branches. Heavier, suited to versioned products.

A good answer: "usually GitHub Flow with small branches and short PRs — because a large PR effectively doesn't get reviewed."

What's the difference between git switch, git restore and git checkout?

▾Intermediate
CommandDoes
git switch mainSwitch branch
git switch -c feat/xCreate and switch to a branch
git restore file.pyRevert a file to its last committed version
git restore --staged file.pyUnstage it
git checkoutBoth jobs above; old and ambiguous

checkout still works, but since it does two completely different things, it's a source of mistakes. Git 2.23 and later introduced two separate commands, and using them in an interview shows you follow the tool's updates.

What qualities does a good Pull Request have?

▾Intermediate
  • Small: under a few hundred lines of change. A large PR effectively doesn't get reviewed.
  • One goal: one feature or one bug fix, not a mix of five things.
  • Explains "why": what the problem was, what options were considered, why this approach.
  • How it was tested: how you can be sure it works.
  • Self-review: read your own diff before requesting review.
  • No unrelated changes: reformatting a whole file makes the diff unreadable.

Add one more item for data projects: the change's effect on downstream tables and dashboards. Changing one column in a model can break ten reports.

How do you search history?

▾Intermediate
git log --oneline --graph --decorate -n 20
git log -S "customer_ltv" --oneline        # which commit added or removed this string
git log -p -- src/transform.py             # a file's history with the actual changes
git log --since="2 weeks ago" --author="milad" --oneline
git show a1b2c3d                           # a commit's details
git diff main...feature/x                  # difference from the branch point

The -S switch, called "pickaxe," is fantastic: when you want to know where a strange piece of business logic came from, this command finds the exact commit.

How do you manage Jupyter notebooks in Git?

▾Intermediate

A notebook is a JSON file that also carries cell output and metadata, so its diff is unreadable and its conflicts are a nightmare.

pip install nbstripout
nbstripout --install          # automatic hook: outputs stripped before commit

# or with pre-commit
# - repo: https://github.com/kynan/nbstripout
#   hooks: [{id: nbstripout}]

# view a readable diff
pip install nbdime && nbdime config-git --enable
A more serious risk than a messy diff is data leakage: cell output may contain a sample of real customer data, or even a printed token, and it stays in history forever.

How do tagging and release versioning work?

▾Intermediate
git tag -a v1.4.0 -m "Add churn model and retention dashboard"
git push origin v1.4.0
git tag -l "v1.*"
git describe --tags            # closest tag to the current state
git checkout v1.3.0            # go back to a specific version

Semantic versioning: major.minor.patch — breaking change, new feature, bug fix.

In a data project, tagging before any significant change to metric logic is very valuable: when someone asks "why does this report's number differ from three months ago," you can jump straight to that version and compare.

Cut down repeated conflicts with rerere.

▾Intermediate
git config --global rerere.enabled true

With this enabled, Git remembers the resolution you chose for a conflict, and if the same conflict comes up again, applies it automatically.

Especially useful during repeated rebases on a long-lived branch, where one conflict can recur dozens of times. One of Git's least-known and most useful settings.

How do you keep a fork in sync with the upstream repository?

▾Intermediate
git remote add upstream https://github.com/original/repo.git
git remote -v
git fetch upstream
git switch main
git merge upstream/main        # or: git rebase upstream/main
git push origin main

In the fork model, origin is your copy and upstream is the original repository. Before starting any new work, sync your own main with upstream and branch off it — this keeps conflicts to a minimum.

Advanced 9

G07

There's a bug somewhere in history and you don't know where it entered.

▾Advanced
git bisect start
git bisect bad                 # the current version is broken
git bisect good v1.2.0         # this version was healthy
# Git checks out the midpoint; test it and say:
git bisect good   # or  git bisect bad
git bisect reset               # finish
Bisecting: 6 revisions left to test after this (roughly 3 steps) ... a1b2c3d is the first bad commit

Binary search: across 1000 commits, only about 10 tests are needed. If you have automated tests, you can fully automate it with git bisect run pytest -q.

G08

You wiped out your work with git reset --hard. How do you recover?

▾Advanced
git reflog
a1b2c3d HEAD@{0}: reset: moving to HEAD~3 9f8e7d6 HEAD@{1}: commit: add retry logic 5c4b3a2 HEAD@{2}: commit: fix null handling
git reset --hard 9f8e7d6       # go back to that point
# or without moving the branch:
git cherry-pick 9f8e7d6

Until garbage collection runs (default around 30 days), commits are still in the reflog. Knowing reflog is a real point-scorer in an interview, because it shows you've survived this exact situation.

G09

What does a good commit message look like?

▾Advanced
feat(etl): add retry with backoff to orders API client

Without retry, a transient 429 error would stop the entire
nightly run. Added three attempts with exponential delay,
and the final failure is now logged.

Refs: DATA-482

The Conventional Commits pattern: feat, fix, refactor, docs, test, chore.

Rule: a short, imperative first line, and the "why" in the body. The code itself already says "what"; the commit should say "why."

G10

What should never be in Git in a data project?

▾Advanced
# .gitignore
.env
*.csv
*.parquet
data/
.venv/
__pycache__/
.ipynb_checkpoints/
*.log
  • Secrets and tokens → an environment variable or a secret manager.
  • Raw data and outputs → object storage or DVC, not Git.
  • Notebooks with output → strip before committing (nbstripout), or diffs become unreadable and sensitive data can leak.

The last item is specific to a data role, and few people mention it in an interview.

Squash several commits into one.

▾Advanced
git rebase -i HEAD~4
# in the editor:
#   pick   a1b2c3d feat: add churn model
#   squash 9f8e7d6 fix typo
#   squash 5c4b3a2 wip
#   squash 2b1a0c9 address review comments

# or when merging into main
git merge --squash feature/x
git commit -m "feat: add churn model"
Only do this on a personal branch that hasn't been pushed, or that no one else is working on. Rewriting shared history forces coworkers to manually recover and gives you a bad reputation on the team.

The benefit of squashing is that main's history stays clean: every commit is one meaningful, revertible change, not twenty "wip" and "fix typo" commits.

How do Git hooks and automated pre-commit checks work?

▾Advanced
# .pre-commit-config.yaml
repos:
  - repo: https://github.com/psf/black
    rev: 24.4.2
    hooks: [{id: black}]
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.5.0
    hooks: [{id: ruff, args: [--fix]}]
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks:
      - id: detect-private-key
      - id: check-added-large-files
      - id: end-of-file-fixer
pip install pre-commit && pre-commit install
git commit -m "..."     # the hooks run automatically

The last two hooks are vital for a data project: they stop private keys and large data files from being committed — two mistakes that are very costly to fix once they've happened.

Remove a sensitive file that's already been pushed from history.

▾Advanced
# 1) immediately revoke the credential — the most important step
# 2) rewrite history
pip install git-filter-repo
git filter-repo --path secrets.env --invert-paths

# 3) force push and notify the team
git push origin --force --all
git push origin --force --tags
This changes every commit ID, so everyone with a local copy must re-clone from scratch. Also, copies in forks, pull requests and the hosting service's cache may still remain. This is why revoking the credential always matters more than deleting the file.

Prevention is better: enable the detect-private-key hook and secret-scanning tools in pre-commit so this never happens in the first place.

What should the CI workflow look like for a data project?

▾Advanced
# .github/workflows/ci.yml
name: ci
on: [pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: {python-version: "3.11"}
      - run: pip install -r requirements-dev.txt
      - run: ruff check .
      - run: black --check .
      - run: pytest -q --cov=src
      - run: dbt deps && dbt build --target ci

The last step is specific to a data project: models are built on a temporary schema and data-quality tests are run. This way, a transformation logic error is caught before it reaches production, not after a manager sees the wrong number.

The repository has grown large and slow. What are your options?

▾Advanced
git count-objects -vH                      # the repository's actual size
git gc --aggressive --prune=now            # compaction
git clone --depth 1 <url>                  # shallow clone for CI
git clone --filter=blob:none <url>         # partial clone, files fetched on demand
git sparse-checkout set src/ tests/        # only part of the working tree

# large binary files
git lfs install && git lfs track "*.parquet"

The root cause is usually data files that got committed once and stayed in history forever. A shallow clone in CI gives a quick improvement, but the real fix is keeping data out of Git from the start.

Behavioral, scenario and closing questions

Don't underrate this section. In most data hiring processes, the final call between two candidates with close technical strength is made on exactly these skills.

Behavioral answer cheat sheet — the STAR structure

Tell every story in 90 seconds using these same four steps.
Situation
Context in one sentence: where, what project, what constraint
Task
Your own specific responsibility, not the team's
Action
The bulk of the answer; what you did and why you chose that path
Result
A measurable outcome + one lesson learned

Basics 7

B01

Tell me a little about yourself.

▾Basic

A three-part structure, around 90 seconds:

  1. Present: what you do now and what area you've worked in.
  2. Relevant past: one or two projects directly relevant to this role — with a number.
  3. Future: why you want this specific position.
Two common mistakes: walking through your résumé line by line from high school, and a generic answer that fits any company. Your last sentence must explicitly connect to this specific company.
B02

Describe a project you're proud of.

▾Basic

Suggested structure: business problem → data and tools → a specific technical challenge → solution → measurable impact.

If your background is research, translate it into business language: optimization modeling and data analysis in research is the same skill as "defining a problem from messy data and reaching a decision." Add one sentence about data volume and tools (Python, SQL) to bridge to the role.

Why this company and this role?

▾Basic

A three-part structure that always works:

  1. Something specific about the company that you actually researched: their product, market, or data challenge.
  2. A bridge to your own experience: where your work connects to this challenge.
  3. What you want to learn that's possible here and not elsewhere.
A generic answer ("it's a reputable company and I want to grow") is the worst option because it fits any company. Spend ten minutes and find one real detail.

What are your strengths and weaknesses?

▾Basic

For strength: one trait relevant to the role, with a short example and its outcome. Not a list of five.

For weakness: something real that you've worked on and can show progress on.

Fake weaknesses ("I'm a perfectionist", "I work too hard") are spotted immediately and reduce trust. A good example: "I used to fall behind on documentation; now I've made writing the metric definitions part of the definition of done."

How do you explain a technical concept to a non-technical audience?

▾Basic
  • Start with the result, not the method: "the churn rate in this segment is double" before "I built a logistic model."
  • Use a familiar analogy.
  • Put the number in business context: this 3% means how many million tomans.
  • State uncertainty simply, without statistical jargon.
  • End with "so what should we do."

If the interviewer says "assume I'm the sales manager," they're testing exactly this skill. Using technical jargon at this moment counts against you, not for you.

Where do you see yourself in five years?

▾Basic

State a direction, not an exact job title. For example: going deeper into data architecture and owning a domain end to end, or moving toward product analytics and working closer with decision-makers.

Two traps: ambition unrelated to this role ("I want to start my own startup") and an aimless answer ("whatever comes up"). Describe a path where this job is a logical first step.

How do you handle time pressure and deadlines?

▾Basic
  • I negotiate scope early: what's the minimal version that makes the decision possible.
  • I show something early and incomplete rather than late and complete.
  • I flag risk early, not on the last day.
  • I don't sacrifice data quality for speed; a wrong report is worse than a late one.

Be sure to say that last sentence. In data roles, a fast mistake costs more than a delay, and the interviewer wants to see that you understand this.

Intermediate 7

B03

Describe a technical mistake you made.

▾Intermediate

A good pattern: a real but non-catastrophic mistake → how you discovered it → how you fixed it → what you changed permanently.

A usable example: "I published a report where a JOIN duplicated rows and doubled sales. I caught it by reconciling against the finance report, corrected and notified people the same day, and since then I've added a row-count check before and after every JOIN in every pipeline." — The point is that it ends in a lasting improvement.

B04

How do you work with a stakeholder who gives a vague request?

▾Intermediate
  1. I ask what decision this number is meant to support.
  2. We write down the metric's precise definition together (time range, filters, unit).
  3. I give a quick early draft and get feedback, instead of disappearing for two weeks.
  4. I document the definition at the end so the discussion doesn't repeat next time.

The first item matters most: "what decision?" — this question turns you from a report-builder into an analytical partner.

B05

What do you do if your analysis result contradicts what management expected?

▾Intermediate

First I recheck the method and data, and ask a colleague to check the query. If the result holds up, I present it with full transparency: assumptions, limitations, and confidence level. I present the result as information, not as a verdict, and offer to test it with a small experiment if doubt remains.

What's being tested: both analytical integrity and communication maturity — neither stubbornness nor giving in.

B06

How do you prioritize between several simultaneous requests?

▾Intermediate

On two axes: the decision's impact (how much money or risk is involved) and real urgency (does it have an external deadline or not). I take repetitive requests out of the queue by building a self-service dashboard. If capacity isn't enough, I say clearly what will slip and decide that with the manager — rather than staying quiet and delivering late.

Describe a time you disagreed with a colleague.

▾Intermediate

A STAR structure emphasizing how it was resolved, not who was right:

  1. What the disagreement was about and why both views were reasonable.
  2. How you moved from a debate of opinions to a debate of evidence: data, a small experiment, or a third opinion.
  3. What the outcome was and what you learned.

If your story ends with "it turned out I was right," it sends the wrong message. The best version is one where your view changed after hearing the other side's reasoning, or you reached a third solution together.

How do you document your work?

▾Intermediate
  • Metric definitions: formula, filters, owner and last-changed date.
  • Code: in Git, with commit messages that explain "why."
  • Pipeline: source, schedule, dependencies, and what to do if it fails.
  • Reports: every dashboard gets a guide page with number definitions.
  • Architecture decisions: what options were considered and why this one was chosen.

A good documentation test: if you took two weeks off, could someone else fix a broken pipeline? If the answer is no, the documentation isn't enough.

What do you do when you don't have the data you need?

▾Intermediate
  1. I pin down exactly what's missing and whether it's really needed for this decision.
  2. I look for a substitute or proxy metric.
  3. If possible, I do manual sampling or a small survey.
  4. I present the analysis with explicit assumptions and an uncertainty range.
  5. At the same time, I raise a request to start collecting that data going forward.

"We don't have the data so it can't be done" is the weakest answer. A good analyst still makes a better-than-guessing decision with incomplete data, as long as the limitations are stated clearly.

Advanced 6

B07

Scenario: a dashboard's number has suddenly dropped 40%. What do you do in the first 30 minutes?

▾Advanced
  1. Is it a data problem or a real one? I first check the raw incoming record count; if that's dropped too, it's likely a pipeline or logging failure.
  2. I find the exact time of the break and compare it against the last deployment or schema change.
  3. I break the drop down by dimension: if it's only one platform or one region, the root cause is narrower.
  4. I share preliminary findings early, even if not yet conclusive — silence during an incident is the worst option.

Be sure to say the order is "data quality first, business reality second"; most sudden, large drops are pipeline problems, not market ones.

B08

How do you keep yourself up to date?

▾Advanced

A good answer is specific, not generic: name one or two sources you actually follow, one specific thing you've recently learned, and where you applied it.

If you have a research or publication background, present that as an advantage: the habit of reading technical sources and following new methods is exactly what data work needs.

B09

"Do you have any questions for us?" — what to ask

▾Advanced

Have three or four questions ready. These both give you useful information and show you're serious:

  • How is success in this role measured in the first three months?
  • What's the current data setup — do you have a warehouse, or query the operational database directly?
  • What's the data team's biggest current problem? Data quality, reporting speed, or something else?
  • How do analytical requests reach the team and get prioritized?
  • How is the team split between ad-hoc work and long-term projects?
In the first technical round, don't ask about salary and benefits unless they raise it themselves. Save it for a later stage.
B10

The last-30-minutes checklist before an interview

▾Advanced
  • Do one quick pass of this page's SQL and pandas cheat sheets.
  • Have three STAR stories ready: a technical success, a mistake, a hard collaboration.
  • Solve two exercises from the live-coding section out loud to warm up.
  • If it's online: check your internet, mic, camera and a quiet space; keep a simple editor and a SQL client open.
  • Keep paper and pen at hand — sketching a JOIN or a schema on paper is faster thinking.
  • Remember: when you don't know an answer, say "I'm not sure, but here's how I'd approach it..." and think out loud. Silence or a confident guess are the worst options.

And one last point: the interviewer wants you to succeed too. Ask clarifying questions; it's not weakness, it's a sign of a sound working method.

Scenario: you discover a report published three months ago was wrong. What do you do?

▾Advanced
  1. Confirm: make sure it's really wrong and scope out the extent.
  2. Notify quickly: tell your manager and the main consumers, the same day. Hiding it multiplies the problem.
  3. Assess the impact: what decisions were made based on this number?
  4. Fix and publish the correct version with a clear note about the change.
  5. Prevent recurrence: add an automated test so this same error can't silently happen again.

The interviewer is testing honesty and maturity here, not technical ability. Emphasis on items two and five scores the most.

Scenario: your manager wants you to change the analysis so it produces the result they want.

▾Advanced

Suggested approach:

  • First assume good intent: maybe they mean a different, genuinely valid metric definition or segmentation.
  • Ask exactly what they think is wrong, and why; sometimes they have a valid point.
  • If the request is selectively dropping data or changing the definition with no justification, say clearly but respectfully that you can't guarantee the result.
  • Suggest presenting both versions with explicit assumptions so the reader can judge for themselves.
  • If the pressure continues, put it in writing and escalate if necessary.

Balance matters here: neither stubbornness nor giving in. A good line: "I'll implement whatever definition we agree on, as long as that same definition is written in the report." Transparency resolves most of these tensions.

Milad Shabani — data analyst
Compiled, edited and designed by

Milad Shabani

This collection comes from hands-on experience in data analysis, modeling and business intelligence projects. It is not meant to be a dry list of questions: every question comes with a complete answer, runnable code and the reasoning behind it, so it works both for learning and as a reference at work. If you found it useful, I would be glad to hear your feedback or to collaborate on data projects.

Continue from