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
No question matches these filters. Shorten the search term or remove some filters.
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.
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
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
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
Type
WHERE clause
Rollback
Table structure
DELETE
DML
Yes
Yes
Kept
TRUNCATE
DDL
No
Yes in Postgres, not in MySQL
Kept
DROP
DDL
No
Yes in Postgres
Removed
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;
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 table
NULL
Purpose
PRIMARY KEY
One
Not allowed
Unique row identifier
UNIQUE
Many
Usually allowed
Prevents duplicate values
FOREIGN KEY
Many
Allowed
References 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;
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;
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
Lifetime
Indexable
Best for
CTE
That query only
No
Readability and staging
TEMP TABLE
Until the session ends
Yes
A heavy intermediate result used several times
VIEW
Permanent
No (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';
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;
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;
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;
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;
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
OLTP
OLAP
Purpose
Recording transactions
Analysis and reporting
Query pattern
Reading/writing a few rows
Columnar scans over large volumes
Data model
Normalized
Star / denormalized
Storage
Row-based (Postgres, MySQL)
Columnar (ClickHouse, BigQuery)
Example
Placing an order
Monthly 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;
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.
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
I run EXPLAIN ANALYZE to see the actual plan and the bottleneck.
I see which step takes the most time and has the biggest gap between estimate and reality.
I check that the conditions are sargable and that a suitable index exists.
I reduce the amount of data returned: remove SELECT *, filter earlier, remove unnecessary DISTINCTs.
I remove unnecessary JOINs and repeated correlated subqueries.
If it is a recurring analytical query, I build a materialized view or an aggregate table.
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;
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;
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;
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;
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;
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;
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;
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;
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;
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;
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;
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;
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;
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;
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;
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;
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;
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;
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;
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;
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):
Confirm the problem: is it a real drop or normal fluctuation? Compare with the previous week and the same week last year.
Decompose: is the drop in the number of orders or in AOV? revenue = orders × AOV.
Slice by dimensions: break it down by city, channel, device, product category, new versus returning users.
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;
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.
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;
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;
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;
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;
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;
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;
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;
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;
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;
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;
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;
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;
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;
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;
-- 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;
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;
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;
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;
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;
-- 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
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])
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}")
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:])
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.
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))
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
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?
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?
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.
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.
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
Error
Meaning
Example
SyntaxError
The code structure is wrong
Forgetting the colon after if
NameError
Undefined variable
A typo in a variable name
TypeError
Operation on an unsuitable type
"5" + 5
ValueError
Right type, invalid value
int("abc")
KeyError
The key is not in the dictionary
d["x"]
IndexError
Index out of range
xs[10] on a 3-element list
IndentationError
Incorrect indentation
Mixing 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.
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.
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.
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.”
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”.
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.
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.
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
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())
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))
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.
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))
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)
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}))
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.
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
Operation
list
set / dict
Access by index
O(1)
—
Membership test in
O(n)
O(1) average
Append to the end
O(1) amortized
O(1)
Insert at the start
O(n)
—
Sorting
O(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))
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))
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.
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], "...")
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 Sort
Quick Sort
Average time
O(n log n)
O(n log n)
Worst case
O(n log n)
O(n²)
Extra memory
O(n)
O(log n) in the in-place version
Stable
Yes
No
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"))
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())
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])
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))
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))
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))
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())
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.
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.
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
Error
Meaning
Fix
#N/A
Value not found
IFNA, or check the data type and extra spaces
#REF!
Reference to a deleted cell
Rewrite the formula
#VALUE!
Unsuitable data type
Check that the input is a number
#DIV/0!
Division by zero
IFERROR or a condition on the denominator
#NAME?
Unknown function or range name
Spelling and Name Manager
#SPILL!
The array's spill range is occupied
Clear 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.
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
Click in the data and press Ctrl+T to turn it into a table.
On the Insert tab, choose PivotTable and pick where to place it.
Drag the “City” field to Rows.
Drag the “Amount” field to Values; it is summed by default.
Drag the “Month” field to Columns to make it a two-dimensional table.
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
Shortcut
Action
Ctrl+T
Convert 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+L
Toggle filters on and off
F4
Cycle between absolute and relative reference modes
Ctrl+D / Ctrl+R
Copy from the cell above / to the left
Ctrl+Shift+V
Paste Special
Alt+H+O+I
Auto-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.
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
Reduce volatile functions (NOW, TODAY, OFFSET, INDIRECT, RAND); they recalculate the whole sheet on every change.
Replace whole-column references (A:A) with a table or an exact range.
Replace heavy repeated formulas with a single computed helper column.
Paste Values for static data.
Limit conditional formatting on huge ranges.
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
Select all cells and untick Locked in Format Cells.
Select only the cells with formulas and lock them again.
On the Review tab, apply Protect Sheet with a password and allow selecting locked cells.
Hide sensitive formulas with the Hidden option so they don't show in the formula bar.
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
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:
Data layer: raw sheets or Power Query queries; untouched and free of decorative formulas.
Calculation layer: intermediate tables, measures, helper columns; hidden from the end user.
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:
Import both files with Power Query (Data → Get Data), not by copy and paste.
Set the column types and remove blank and duplicate rows.
Merge Queries on customer_id as a Left Outer join; compare the row counts before and after.
Build a month column and load to the Data Model.
A PivotTable on the model, with the following measure:
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)
)
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
A guide sheet at the front: the file's purpose, data source, refresh date, the definition of each metric and the owner's name.
Separate the layers: raw data, calculations and presentation on separate sheets; hide the intermediate sheets.
Conventional colors: inputs blue, formulas black, references to other sheets green.
Remove leftovers: broken named ranges, conditional formatting over millions of cells, test sheets.
Check for errors with Formulas → Error Checking and a search for #REF!.
Return to cell A1 on every sheet and save with the first sheet active.
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:
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.
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.
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
Type
Use
Risk
One-to-many (1:*)
The standard case: dimension to fact
No
One-to-one (1:1)
One entity split across two tables
Usually they should be merged
Many-to-many (*:*)
When neither side has a unique key
Unintuitive 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 Column
Measure
When it is computed
When the data is loaded
When the visual is displayed
Stored in memory
Yes, it increases the model size
No
Evaluation context
Row context
Filter context
Best for
Categories, labels, sort keys
Any 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
Connect: Get Data and choose the source (SQL, Excel, folder, web).
Transform: in Power Query, set column types, remove extra columns and clean up.
Model: create relationships in Model view and add a date table.
Calculate: write the main measures in DAX.
Report: lay out the visuals, filters and slicers.
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
Question
Visual
Trend over time
Line chart
Comparing categories
Bar or column chart
A single key number
Card or KPI
Row-level details
Table or matrix
Relationship between two numeric variables
Scatter chart
Share of a total (at most 4 parts)
Donut, with care
Geographic distribution
Map or filled map
Breaking down a change
Waterfall
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
Level
Scope
Example use
Visual
That visual only
Showing the top 10 products in one chart
Page
Every visual on the page
A page dedicated to one product category
Report
Every page
Excluding test orders from the whole report
Drillthrough
Target page, based on the user's selection
Details 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
Report
Dashboard
App
Number of pages
Multi-page
One canvas
A collection of reports
Source
One semantic model
Tiles from several reports
The content of a workspace
Interaction
Full filtering and drill
Limited, click on a tile
Like a report
Alerts
No
On tiles
—
Audience
Analyst
Manager
End 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.
Situation
Recommendation
Share at one point in time, 2 to 3 parts
Pie or donut is acceptable
Share at one point in time, more than 3 parts
Bar chart with percentage labels
Change in share over time
100% stacked bar or area chart
Share with a hierarchy
Treemap
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
Import
DirectQuery
Report speed
Very fast
Depends on the source
Data freshness
As of the last refresh
Real time
Data volume
Limited by capacity
Unlimited
DAX capabilities
Full
Limited
Load on the source
Only during refresh
With 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
In Power Query, create two date/time parameters named exactly RangeStart and RangeEnd.
Filter the fact table's date column with these two parameters.
Enable incremental refresh on the table: for example “archive 5 years, refresh 10 days”.
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
Turn on Performance Analyzer and see which visual is slow, and how much of it is DAX query versus visual display.
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.
Simplify the model: remove unused columns, reduce cardinality (for example by splitting date and time), remove heavy calculated columns.
Reduce the number of visuals per page; each visual is a separate query.
Limit page- and report-level filters and use sensible defaults.
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.
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.
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:
An actual bar with a target line on the same axis (a combo chart with one column and one line).
A bullet chart, which shows the value, the target and the performance ranges in little space.
A variance chart: show only the difference, positive in green and negative in red.
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.
Remove unused columns, especially text ids and GUIDs.
Split a datetime column into date and time — cardinality drops sharply.
Round high-precision decimals or change them to Fixed Decimal.
Move calculated columns to Power Query or the source.
Limit the history you keep and set up incremental refresh.
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
Open the refresh history in the model settings and read the exact error text.
If it's an authentication error: the source credentials have expired or the password has changed.
If the source is on-premises: check the gateway's status, its Windows service and the data source mapping.
If it's a timeout: a heavy query or no query folding; move the logic to the source.
If it's a memory error: the model has exceeded capacity; incremental refresh or fewer columns.
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 BI
Metabase
Model layer
Strong, with DAX and a semantic model
Light, mostly query-based
Learning curve
Steep
Gentle
Self-service for non-technical users
Good, but needs a ready model
Very fast
Cost
Per-user license
Free open-source edition
Best for
Complex models and heavy calculations
Quick 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
Problem
Effect
Fix
Truncated vertical axis
A small difference looks huge
Start at zero or mark it explicitly
Dual axis
Creates a false correlation
Two separate charts, or normalize to an index
Area scaling
Exaggerates the difference several times
Length instead of area
Cherry-picked time range
Manufactures the desired trend
A complete, meaningful range
3D effects
Distort the reading of values
Never use 3D charts
Comparing unequal periods
Meaningless comparison
Periods 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.
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
On the Modeling tab, click New Measure.
Write the name and formula in the formula bar: Total Sales = SUM(Sales[Amount]).
Set the number format and decimal places under Measure tools.
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:
There is no relationship between the dimension table and the fact table, or it is inactive.
The column you used in the visual comes from the fact table itself, not from the connected dimension table.
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?
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])))
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.
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.
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]
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.
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.
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
)
)
)
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.
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
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.
Replace FILTER on the fact table with a column filter.
Remove nested iterators; a SUMX inside a SUMX can usually be rewritten.
Put repeated calculations in a VAR.
Move heavy calculated columns to Power Query or the source.
Reduce the cardinality of filter columns.
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.
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
Building a calculated column instead of a measure. The result: a bloated model and non-dynamic calculations.
Using FILTER on the fact table. The result: millions of rows scanned in the Formula Engine.
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.
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.
-- 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 )
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])}})
What is Power Query and how does it differ from DAX?
▾Basic
Power Query (M)
DAX
When it runs
At load and refresh time
When the visual is displayed
Job
Extracting, cleaning, shaping
Calculation and aggregation
Letter case
Case-sensitive
Case-insensitive
Mental model
Step by step, like ETL
Context-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.
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
Action
Path
First row as headers
Home → Use First Row as Headers
Change column type
Click the type icon next to the column name
Remove extra columns
Select the needed columns → Remove Other Columns
Remove blank and duplicate rows
Home → Remove Rows
Split column
Split Column by Delimiter
Replace values
Transform → Replace Values
Custom column
Add Column → Custom Column
Grouping
Transform → 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
Group
Example
Note
File
Excel, CSV, JSON, XML, PDF, folder
A folder for combining several files with the same structure
Databases
SQL Server, Postgres, Oracle, MySQL
They have the best folding
Online services
SharePoint, Dataverse, D365, Google Analytics
Request rate limits
Web
APIs with OData or JSON, HTML tables
Watch 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}
})
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).
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.
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
See how far folding is preserved (View Native Query on the last step).
Move folding-breaking steps (custom columns, merges with another source, Index) as close to the end as possible.
Remove unneeded columns at the very start of the query, not at the end.
Use Table.Buffer only when it is really necessary, because it breaks folding itself.
Review multiple merges; each merge can read the source several times.
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:
Source (SQL / data warehouse): broad filtering, JOINs, heavy aggregation, business logic shared by several consumers.
Dataflow: cleaning shared across several reports, organizational reference tables.
Power Query in the file: shaping specific to that report, renaming and retyping, unpivot.
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
Click each step and look at the preview of its result; find the step where the problem starts.
Turn on Column Quality and Column Distribution to see the percentage of errors and blanks in each column.
On the column with errors, use Keep Errors so only the faulty rows remain.
Right-click and use View Native Query to see how far folding is preserved.
In the Advanced Editor, give the steps meaningful names so the code is easier to read.
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.
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”.
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
Star
Snowflake
Dimensions
Flat and denormalized
Normalized into several tables
Number of JOINs
Few
Many
Query speed
Higher
Lower
Redundancy
Yes
No
Ease of understanding for users
Simple
Complex
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?
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
Layer
Content
Data lifetime
Staging
A raw copy of the source, no transformation
Temporary, usually until the next run
ODS
Integrated, near-real-time, normalized data
Current state
Data Warehouse
Dimensional model, with history, organization-wide
Long term
Data Mart
A subject-specific slice for one business unit
Long 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.
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?
Excellent, because values of the same kind sit together
Best for
OLTP
Data 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.
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
Type
Example
Behavior
Additive
Sales amount, quantity
Can be summed across every dimension
Semi-additive
Inventory, account balance
Can be summed across every dimension except time
Non-additive
Percentage, rate, ratio, unit price
Can'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.
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
Kimball
Inmon
Direction
Bottom-up
Top-down
Starting point
A data mart for one process
A normalized central warehouse
Model
Dimensional from the start
3NF, then dimensional marts
Time to value
Short
Long
Risk
Inconsistency between marts
A 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.
Process
Date
Customer
Product
Store
Supplier
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:
Flattening to a fixed number of levels: Level1 to Level6 columns; simple and fast, but with empty levels and a maximum depth.
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.
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:
Choose the business process — online sales. Which decision is this supposed to support?
Declare the grain — one row per line item of each order.
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:
A negative row in the same fact — simple, but the grain is muddied and order counts come out wrong.
A separate fact for returns — the cleanest option; conformed dimensions stay shared and you can drill across.
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
Key uniqueness in each dimension, and for SCD Type 2, uniqueness of the natural key plus validity period.
No overlapping periods among an entity's history rows.
Exactly one current row for each natural key.
No orphan keys in the fact.
Control totals reconciled with the source system for a few sample days.
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
Identify: using data lineage, find every table and file that contains the person's identifier — including staging, backups and exports.
Isolate: from the start, keep identifying information in a separate dimension so a deletion touches only one place.
Anonymize instead of delete: keep the fact row but move its key to a “deleted” row; that way historical statistics don't break.
Record: document the date and scope of fulfilling the request.
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
ETL
ELT
Where transformation happens
An intermediate server
Inside the target warehouse
Raw data
Usually not kept
Stays in the target
Reprocessing
Requires reading the source again
From the existing raw data
Tools
Dedicated ETL tools
SQL and dbt
Best for
Limited resources, a weak target
Scalable 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.”
Extract — reading from the source with a defined scope (a time range or a watermark).
Land — writing raw, immutable data for later reprocessing.
Validate — checking the structure, row counts and critical values.
Transform — cleaning, standardizing, joining and modeling.
Load — an idempotent write to the target.
Verify — reconciling control totals and checking quality.
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
Read the run log and find the exact error message and the step that failed.
See whether the failure was complete or partial; if partial, check the state of the target.
If it's a transient error (network, lock, rate limit), re-run it.
If it's a data error, take a sample of the faulty records and find the cause.
Let the consumers know today's report is delayed.
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.
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
Method
Advantage
Limitation
An updated_at column
Simple, no special access needed
Doesn't see deletes; depends on the column being correct
Increasing id
Simple and fast
Only new inserts, not updates
Comparing a hash of the whole row
Catches any change
Requires reading the whole source
CDC from the transaction log
Every operation including deletes, low load
Setup and database-level access
Triggers in the source
Precise
Load 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.
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 type
Example
Response
Transient
Network outage, rate limit
Retry with exponential backoff
Data
Invalid date, missing key
Divert the record to an error queue, keep going
Structural
A column was removed
Stop completely and alert
Logical
Totals don't match the source
Don'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);
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.
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.
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:
Execution — success or failure, duration, number of retries.
Volume — today's row count compared with a moving average; a sudden drop should raise an alert.
Quality — rate of empty values, orphan keys, rate of rejected records.
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;
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
Measure — time each step separately; usually 80 percent of the time is in one step.
Reduce the data — move from full to incremental loading; this is usually the biggest win.
Push processing to the source — do filtering and aggregation in the query, not in Python.
Batch — replace single-row inserts with bulk inserts or COPY.
Parallelize — run independent tasks at the same time.
Format — move from CSV to Parquet.
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
Compare the definitions — filters, time range, included statuses, currency and tax.
Check the time zone and day boundary; this is the most common cause of small discrepancies.
Freshness — is one of the sources behind?
Go down to record level — take one day and one segment and list the differing records.
Decide the source of truth and write it down.
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.
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
Option
Best for
Hidden cost
Managed tools (Fivetran and similar)
Standard, common sources
Volume-based pricing, little flexibility
Open-source tools (Airbyte, Meltano)
More control at lower cost
Infrastructure maintenance
Custom code
Complex logic or a non-standard source
Development and maintenance time
dbt for transformation
Wherever the target speaks SQL
Limited 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:
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.
Event-driven runs: the source announces it is ready itself, with a message or a webhook call.
Rolling window: each run reprocesses the last two or three days as well as today, so late records aren't missed.
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
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
Leading
Lagging
Nature
Predicts the future
Reports the past
Ability to influence
High, right now
Low, it has already happened
Example
Number of new leads, activation rate, support response time
Quarterly 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 metric
Meaningful alternative
Total number of registered users
Monthly active users
Number of app downloads
Activation rate and day-7 retention
Number of page views
Conversion rate at each funnel step
Number of followers
Referral 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;
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;
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.
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
Metric
Formula
Tells you
Cart abandonment rate
1 − purchases ÷ add-to-carts
Problems in the checkout process
Return rate
Returns ÷ sales
Product quality or inaccurate descriptions
Items per order
Number of items ÷ orders
The effect of cross-selling
Repeat purchase rate
Customers with 2+ purchases ÷ total
Loyalty
Share of stock near expiry
Value of aging stock ÷ total inventory
Working capital risk
On-time delivery
Orders within the deadline ÷ total
Operational 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;
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;
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:
Historical: the total gross margin the customer has generated to date. Accurate but backward-looking.
Simple and forward-looking: ARPU × Gross Margin ÷ Churn Rate.
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;
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;
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;
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;
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;
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.
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
Take the baseline from historical data and measure its natural range of fluctuation.
Estimate the theoretical ceiling: how high was the best segment or the best period?
Identify the drivers: which actions are supposed to move this number, and how much effect each one has.
Set the target to be achievable but challenging, with a clear time frame.
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;
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;
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 metric
Guardrail metric
Risk it covers
Click-through rate
Rate of quick bounces back to the previous page
Clickbait headlines
Number of orders
Return and cancellation rate
Excessive discounting
Support response speed
Repeat contact rate
Closing tickets prematurely
Short-term revenue
NPS and retention
Sales 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 type
Example North Star
Two-sided marketplace
Number of successfully completed transactions
Content
Daily meaningful watch time
Enterprise tool
Number of weekly active teams
Online retail
Orders 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.
Written definitions: each metric has one owner, one precise definition, specific filters and a clear unit.
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.
Automated tests: checks for key uniqueness, empty values, allowed ranges and agreement with the financial source.
Versioning: a definition change must be dated and announced, otherwise the historical trend breaks silently.
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:
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.
When did it start? Find the exact break point and compare it with the deployment calendar, metric definition changes and campaigns.
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.
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;
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
Look at the same metric's historical range of fluctuation: what is its weekly standard deviation? How many times that is the current change?
Compare with the same period last year to remove seasonality.
If there is little data, build a confidence interval; rates based on small samples fluctuate a lot.
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;
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
Understand the business model: where the money comes from, where value is created, how long the purchase cycle is.
Choose a North Star metric that represents customer value.
Break it into a metric tree so each team knows which branch it affects.
Add guardrail metrics so local optimization doesn't happen.
Write the definitions down and build a single implementation.
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
Model
Logic
Bias
Last click
All credit to the last touch
Ignores awareness channels
First click
All credit to the first touch
Ignores closing channels
Linear
Equal split
Treats every touch as equally valuable
Time decay
Closer touches get more weight
Depends on the chosen decay rate
Data-driven
Each channel's statistical contribution
Needs data volume and a model
Incrementality test
Turn the channel off and measure the effect
Expensive, 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:
First layer — three to five KPIs with period comparisons and status against target.
Second layer — a breakdown of those KPIs following the metric tree: if revenue dropped, which component is to blame.
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))
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
Linear
Logistic
Output
A continuous number
A probability between zero and one
Loss function
Squared error
Log likelihood
Use
Forecasting sales amount
Probability 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}")
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
Problem definition: what is the business question, and how is success measured.
Data collection: sources, time range, quality and labels.
Feature engineering: building meaningful variables from the raw data.
Training and validation: a baseline model, then more complex models.
Evaluation: with a metric that fits the cost of errors, not default accuracy.
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
Classification
Regression
Output
A label or class probability
A continuous number
Example
Will this customer churn?
How much will we sell next month?
Metric
AUC, F1, precision, recall
RMSE, MAE, MAPE, R²
Common algorithms
Logistic, trees, boosting
Linear, 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.
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?
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?
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.
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?
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?
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))
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.
Situation
Sign
Action
High bias
Both scores low
A more complex model, more features, less regularization
High variance
A large gap between training and test
More 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]}")
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
Change the metric: use PR-AUC, recall or F1 instead of accuracy.
Use class weights: class_weight="balanced", or scale_pos_weight in XGBoost.
Resampling: undersample the majority class or synthetically oversample the minority with SMOTE — only on the training data.
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
Method
Best for
Risk
One-Hot
Few categories, linear models
Dimension explosion
Ordinal
Truly ordinal categories
Imposing a false order
Target Encoding
High cardinality
Leakage, if done without CV
Native categorical
LightGBM and CatBoost
Limited 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 Tree
Random Forest
Gradient Boosting
Construction
One tree
Parallel trees on random samples
Sequential trees, each correcting the previous one's errors
Variance
High
Low
Medium
Typical accuracy
Low
Good
Usually the best on tabular data
Parameter tuning
Simple
Simple
Sensitive
Interpretability
High
Medium
Low (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))
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}")
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))
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.
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
Build a baseline: last month's value, or the average of the same month in previous years. Any complex model has to beat this.
Decompose the series: trend, seasonality, residual.
Build features: lags, moving averages, holidays, calendar events (for example Nowruz and Yalda in Iran).
Choose a model: SARIMA or Prophet for univariate series, LightGBM with lag features for multivariate problems.
Evaluate with a rolling time window and MAE or MAPE.
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
First understand the cause: is it random, dependent on another variable, or meaningful in itself?
If missingness is itself a signal, build an indicator column.
Impute with the median or mode inside the pipeline, not before splitting the data.
Boosting models such as LightGBM and XGBoost handle missing values natively.
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:
Estimate the value of each customer and the cost of each retention action.
Compute the expected profit of each threshold, not just the statistical metric.
Consider operational capacity: how many calls can the support team make per day?
Measure the real effect with a control group, not with the model's accuracy.
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}")
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.
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.
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.
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é.
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
CSV
Parquet
Layout
Row-oriented
Columnar
Compression
None
High, per-column
Reading a few columns
Whole file
Only those columns
Data type
None, all text
Stored in the file
Built-in stats
None
Min/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 Lake
Data Warehouse
Lakehouse
Data shape
Raw, any format
Structured and modeled
Open files with a table layer
Schema
On read
On write
On write, with evolution
Cost
Low
High
Medium
Transactions
None
Yes
Yes (Delta, Iceberg)
Consumer
Data engineer / scientist
Analyst / BI
Both
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?
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
HDFS
Object Storage (S3 and similar)
Management
You run the cluster yourself
A managed service
Scale
Limited by hardware
Effectively unlimited
Cost
Capital expense
Consumption-based
Compute and storage
Shared nodes
Separate
Renaming a folder
Instant
Full 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.
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.
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?
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.
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
Type
When it's chosen
Cost
Broadcast Hash Join
One side is small
Lowest, no shuffle
Sort Merge Join
Both sides are large
Shuffle and sort
Shuffle Hash Join
One side is medium
Shuffle, no sort
Broadcast Nested Loop
Join with no equality condition
Very 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.
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?
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
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.
Check the shuffle read/write volume. A shuffle of several terabytes usually points to a bad join.
Run df.explain(True) and reduce the number of Exchanges.
Broadcast the small-table join.
Push filters as early in the chain as possible, ideally into the read layer.
Set the shuffle partition count to match the data volume, not the default.
cache() an intermediate result if it's used more than once.
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.
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.
Type
Example
Good fit for
Key-value
Redis, DynamoDB
Cache, sessions, counters
Document
MongoDB
Semi-structured, variable data
Wide-column
Cassandra, HBase
Very high write rates
Graph
Neo4j
Multi-hop relationships, fraud detection
Analytical columnar
ClickHouse, Druid
Real-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:
Schema contract: the data producer commits to a specific structure, and a breaking change is blocked.
Ingest-time tests: key uniqueness, non-null for critical columns, allowed value ranges.
Referential-integrity tests: every foreign key exists in the reference table.
Volume-anomaly detection: today's row count compared against historical average.
Reconciliation with a source of truth: e.g. comparing total revenue against the finance system.
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.
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
Measure: attribute cost to team, pipeline and table so you know where the money goes.
Reduce data scanned: partitioning, columnar format, column selection. Has the biggest impact.
Storage lifecycle: move old data to a cheaper tier and delete after the retention period.
Remove unused pipelines: tables no one has queried in months.
Auto-suspending clusters and cheap capacity for non-critical work.
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;
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
Engine
What it does
Use case
MergeTree
Plain sorted storage
Default for events
ReplacingMergeTree
Keeps the latest version per key
CDC consumption, latest state
SummingMergeTree
Sums numeric columns sharing a key
Pre-aggregated tables
AggregatingMergeTree
Merges aggregate function state
Materialized views with uniq and quantile
CollapsingMergeTree
Cancels a row with a sign marker
Correcting earlier records
Replicated*
Any of the above, with replication
Multi-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;
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;
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
ClickHouse
Cloud warehouse
Postgres
Query latency
Sub-second
Seconds to tens of seconds
Depends on volume
Complex joins
Weak
Strong
Strong
Updates
Expensive
Medium
Cheap
Cost at scale
Low
High
Medium
Management
Self-managed
Managed
Medium
Best use
Real-time event dashboards
Enterprise warehouse
Operational 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
Single-row inserts leading to a too-many-parts error.
The wrong sort key, turning every query into a full scan.
Habitual use of FINAL instead of explicit aggregation.
Daily partitioning over several years of data.
Unnecessary Nullable on columns that are never empty.
Joining a large table with a large table instead of denormalizing or using a dictionary.
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
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.
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
The producer sends a message with an optional key to a topic.
Kafka picks the target partition based on the key.
The message is appended to that partition's log and written to disk.
Consumers read in order from their own offset.
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."
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
Setting
Meaning
group_id
Group identifier; determines how partitions are split
auto_offset_reset
When there's no stored offset: earliest or latest
enable_auto_commit
Automatic offset commits; better off for ETL
max_poll_records
Maximum messages per fetch
max_poll_interval_ms
Maximum gap between two polls before being kicked from the group
session_timeout_ms
Timeout 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.
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.
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?
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
Level
How
Risk
at-most-once
Commit first, then process
Possible message loss
at-least-once
Process first, then commit
Possible duplicate processing
exactly-once
Kafka transactions or idempotent consumption
More 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."
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.
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.
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?
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.
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
acks
Meaning
Risk
0
Doesn't wait for any acknowledgment
Fastest, high chance of loss
1
Only the leader acknowledges
If the leader dies before replicating, data is lost
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
Kafka
RabbitMQ
Model
Durable log, consumer has an offset
Queue; message deleted after ack
Replay
Yes
No
Throughput
Very high
Moderate
Routing
Simple (topic/partition)
Rich (exchange, routing key)
Good fit for
Event streams, analytics, logs
Task queues, RPC
K11
Scenario: your consumer is 2 million messages behind. What do you do?
▾Advanced
First diagnose: use --describe to see if the lag is on every partition or just one.
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.
Short-term fix: batch the writes, raise max.poll.records, add consumers up to the partition count.
If one partition is behind → unbalanced key distribution; the keying strategy needs redesigning.
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
Metric
Meaning
Alert when
Consumer lag
Consumption falling behind
A steady upward trend
Under-replicated partitions
Replicas that have fallen behind
Any value above zero
Offline partitions
Partition without a leader
Any value above zero
Request latency p99
Broker response latency
A sudden spike
Disk usage
Log space
Above 75%
ISR shrink rate
Replicas dropping out of sync
Frequent 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 type
Means
Allowed change
Backward
A new consumer reads old data
Removing an optional field, adding a field with a default
Forward
An old consumer reads new data
Adding an optional field
Full
Both
Only adding or removing a field that has a default
None
No checking
Anything, 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
Click New, then Question.
Pick the source table, e.g. Orders.
In the Filter section, set the condition: status equals paid and date within the last 30 days.
In Summarize, pick the aggregate function: Sum of Amount.
In Group by, set the City field.
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
Element
What it is
Use
Question
A saved query with a visualization type
The basic unit of everything
Dashboard
A layout of several Questions on one page
Answers one topic
Collection
A folder for organization and access control
Separating teams
Model
A base query others are built on
A 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
Question
Suitable visualization
A single key number
Number or Trend
Trend over time
Line
Comparing categories
Bar or Row
Share of a total
Pie, only with few slices
Geographic distribution
Map
Two numeric variables
Scatter
A two-dimensional matrix
Pivot 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
In dashboard edit mode, add a Filter and choose its type (Time, Location, ID, Text).
For each card, specify which column this filter connects to.
For cards based on raw SQL, the filter must connect to a variable of type Field Filter.
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.
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.
Go to Admin, then Table Metadata.
Pick the column and set its semantic type to Foreign Key.
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
See which card is slow (Metabase shows each query's run time).
Check the query in the database with EXPLAIN ANALYZE; the problem is usually on the database side, not Metabase.
Instead of computing heavy aggregations live, build an aggregate table or Materialized View and point the dashboard at it.
Enable caching for heavy cards with a suitable TTL.
Reduce the number of cards per dashboard; each card is a separate query.
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.
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.
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
Criterion
Metabase fits better
Power BI fits better
Data maturity
A clean, ready warehouse
Needs modeling and complex calculations
Audience
Self-service operational teams
Executive and financial reporting
Team skill
Knows SQL
Has Excel and modeling experience
Cost
Open source, no per-user license
Per-user licensing
Data volume
Depends on database capacity
A strong in-memory engine
Governance
Lightweight
Full, 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
Tool
Good for
Limitation
Ready-made SSRS reports
Operational documents like invoices
Low flexibility, specialized development
Financial Reporter
Financial statements
Ledger only
Entity Store and analytical reports
Dashboards inside the system itself
Limited customization
Power BI on a data lake
Enterprise analysis and combining with other sources
Requires setting up data export
Direct OData
Low volume, quick sampling
Slow 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
Table
Meaning
Account
A customer company or organization
Contact
A real person
Lead
An unqualified lead
Opportunity
A sales opportunity with an amount and probability
Quote / Order / Invoice
Quote, order, invoice
Case
A support request
Activity
A 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:
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.
Export to Data Lake / Synapse Link — tables are continuously written to the lake as files. Today's recommended method for large-scale analysis.
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;
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.
Raw tables from the lake sit in the Bronze layer, unchanged.
In Silver, the invoice header and lines are joined with the composite key and names are changed to a readable form.
Dimensions are built from reference tables, and customer becomes historized with an SCD Type 2 pattern.
The Fact table is built at the grain of "one invoice line."
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
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.
Document status: are draft, unapproved documents also being counted?
Currency: is the amount in transaction currency or reporting currency? Which date's exchange rate is used?
Tax and discount: which report shows the gross amount and which shows net?
Returns and adjustments: are reversed documents subtracted?
Data freshness: when was the lake last synced? It might be a few hours behind.
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
Never run a heavy analytical query directly against the operational database; use the lake or BYOD.
In Power BI, choose Import mode and avoid DirectQuery on entities.
Enable Incremental Refresh on transactional tables.
Import only the needed columns; F&O tables have dozens of system columns that never get used.
Drop high-cardinality system columns like RECID and RECVERSION; they inflate the model size for no reason.
Do heavy aggregations at the transformation layer, not in DAX.
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
Pattern
Good for
Consideration
OData / Web API
Low volume, real-time interaction
Request rate limits
DMF with files
Bulk import/export
Asynchronous, needs status monitoring
Business Events
Reacting to an event, e.g. order confirmation
Needs a consumer like Event Grid
Dual-write
Syncing reference data with Dataverse
Debugging queue errors
Data Lake export
Analysis and data warehousing
One-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
Scope out the domain: which modules are in use? Finance only, or supply chain too?
Understand the company structure: how many Legal Entities exist, and how is cross-company reporting defined?
Find the data-export path: lake, BYOD, or neither? How fresh is the data?
Sit with the key user of each module and watch the real process; table names mean nothing without understanding the process.
Clarify the financial calendar and currencies.
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 model
Named, relatively clean tables
Historical tables with AX-era names
Analytical access
Synapse Link, direct Power BI connection
Data Lake export, BYOD, Entity Store
Key
GUID
Numeric RECID + business keys
Multi-company
Usually one environment
DATAAREAID on almost every table
Typical volume
Moderate
Very 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.
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
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.
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.
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.
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.
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?
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
merge
rebase
History
Real, branching
Linear and clean
New commit
Creates a merge commit
Commits are rewritten
Safe on a shared branch
Yes
No
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."
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
Command
Does
git switch main
Switch branch
git switch -c feat/x
Create and switch to a branch
git restore file.py
Revert a file to its last committed version
git restore --staged file.py
Unstage it
git checkout
Both 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?
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."
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?
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?
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:
Present: what you do now and what area you've worked in.
Relevant past: one or two projects directly relevant to this role — with a number.
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:
Something specific about the company that you actually researched: their product, market, or data challenge.
A bridge to your own experience: where your work connects to this challenge.
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
I ask what decision this number is meant to support.
We write down the metric's precise definition together (time range, filters, unit).
I give a quick early draft and get feedback, instead of disappearing for two weeks.
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:
What the disagreement was about and why both views were reasonable.
How you moved from a debate of opinions to a debate of evidence: data, a small experiment, or a third opinion.
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
I pin down exactly what's missing and whether it's really needed for this decision.
I look for a substitute or proxy metric.
If possible, I do manual sampling or a small survey.
I present the analysis with explicit assumptions and an uncertainty range.
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
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.
I find the exact time of the break and compare it against the last deployment or schema change.
I break the drop down by dimension: if it's only one platform or one region, the root cause is narrower.
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
Confirm: make sure it's really wrong and scope out the extent.
Notify quickly: tell your manager and the main consumers, the same day. Hiding it multiplies the problem.
Assess the impact: what decisions were made based on this number?
Fix and publish the correct version with a clear note about the change.
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.
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.