Process Analysis and Improvement Questions
Understanding and improving how work gets done end to end: current-state and future-state process mapping, business process modeling, workflow visualization, and gap and root-cause analysis to make an existing process legible so it can be diagnosed. Covers systematically improving the process with Lean and Six Sigma methods, continuous improvement, bottleneck resolution, and root-cause-driven optimization, and building an operational-excellence culture.
Write a SQL query or set of queries that computes conversion rate uplift pre- and post- a process change. Given two tables:
users(user_id PK, created_at)
events(event_id PK, user_id FK, event_type varchar, occurred_at timestamp)
Assume the process change occurred at '2025-06-01'. Compute conversion rate (users who generated event_type='purchase' within 30 days of user creation) for users created in the 60 days before the change and 60 days after the change, and compute the absolute and relative uplift.
Sample Answer
Approach
Define the pre and post cohorts as users created in the 60 days before and the 60 days after the change date, then for each user check whether a purchase event exists within 30 days of that user's created_at. Aggregate each cohort into a user count and a conversion count, compute the conversion rate per cohort, and take the absolute (percentage-point) and relative uplift between the two rates.
Code
WITH params AS (
SELECT
DATE '2025-06-01' AS change_date,
INTERVAL '60 days' AS win,
INTERVAL '30 days' AS purchase_window
),
cohort_users AS (
SELECT
u.user_id,
u.created_at::date AS created_date,
CASE
WHEN u.created_at::date >= (p.change_date - p.win) AND u.created_at::date < p.change_date THEN 'pre'
WHEN u.created_at::date >= p.change_date AND u.created_at::date < (p.change_date + p.win) THEN 'post'
ELSE NULL
END AS cohort
FROM users u
CROSS JOIN params p
WHERE u.created_at::date >= (p.change_date - p.win)
AND u.created_at::date < (p.change_date + p.win)
),
user_conversion AS (
SELECT
c.user_id,
c.cohort,
CASE WHEN EXISTS (
SELECT 1 FROM events e
WHERE e.user_id = c.user_id
AND e.event_type = 'purchase'
AND e.occurred_at::date >= c.created_date
AND e.occurred_at::date < (c.created_date + (SELECT purchase_window FROM params))
) THEN 1 ELSE 0 END AS converted
FROM cohort_users c
WHERE c.cohort IS NOT NULL
),
agg AS (
SELECT
cohort,
COUNT(*) AS users,
SUM(converted) AS conversions,
ROUND(100.0 * SUM(converted) / NULLIF(COUNT(*), 0), 4) AS conversion_rate_pct
FROM user_conversion
GROUP BY cohort
)
SELECT
a_pre.users AS pre_users,
a_pre.conversions AS pre_conversions,
a_pre.conversion_rate_pct AS pre_conversion_pct,
a_post.users AS post_users,
a_post.conversions AS post_conversions,
a_post.conversion_rate_pct AS post_conversion_pct,
(a_post.conversion_rate_pct - a_pre.conversion_rate_pct) AS absolute_uplift_pct_points,
CASE WHEN a_pre.conversion_rate_pct = 0 THEN NULL
ELSE ROUND((a_post.conversion_rate_pct / a_pre.conversion_rate_pct) - 1, 4)
END AS relative_uplift_fraction
FROM
(SELECT * FROM agg WHERE cohort = 'pre') a_pre,
(SELECT * FROM agg WHERE cohort = 'post') a_post;
Verified against this pinned fixture (10 users, 5 pre-change and 5 post-change, run on DuckDB, a Postgres-compatible SQL engine):
INSERT INTO users VALUES
(1, '2025-04-10'), (2, '2025-04-20'), (3, '2025-05-05'),
(4, '2025-05-15'), (5, '2025-05-25'),
(6, '2025-06-05'), (7, '2025-06-15'), (8, '2025-06-25'),
(9, '2025-07-05'), (10, '2025-07-15');
INSERT INTO events (event_id, user_id, event_type, occurred_at) VALUES
(1, 1, 'purchase', '2025-04-15'), -- pre, converts within 30 days
(2, 2, 'purchase', '2025-04-30'), -- pre, converts within 30 days
(3, 3, 'purchase', '2025-06-14'), -- pre user 3, purchase lands after the 30-day window, does not count
(4, 5, 'view', '2025-05-26'), -- pre user 5, event exists but wrong event_type, does not count
(5, 6, 'purchase', '2025-06-07'), -- post, converts within 30 days
(6, 7, 'purchase', '2025-07-05'), -- post, converts within 30 days
(7, 8, 'purchase', '2025-06-26'), -- post, converts within 30 days
(8, 10, 'purchase', '2025-08-20'); -- post user 10, purchase lands after the 30-day window, does not count
-- user 4 (pre) and user 9 (post) have no events at all
Running the query above against exactly this fixture returns:
| pre_users | pre_conversions | pre_conversion_pct | post_users | post_conversions | post_conversion_pct | absolute_uplift_pct_points | relative_uplift_fraction |
|---|---|---|---|---|---|---|---|
| 5 | 2 | 40.0 | 5 | 3 | 60.0 | 20.0 | 0.5 |
Pre-change conversion is 2/5 = 40%, post-change is 3/5 = 60%, an absolute uplift of 20 percentage points and a relative uplift of 50%.
Key points
- Cohort membership is based on
created_at, not on when the purchase happens, so a user created before the change but converting after it is still correctly counted in theprecohort. - Conversion is defined as at least one
purchaseevent landing inside a fixed 30-day window anchored to that user's owncreated_at, computed per user withEXISTS, not with a join that would double-count users with multiple purchases. - Absolute uplift is a difference in percentage points; relative uplift is a proportional change and is only meaningful once the pre-period rate is non-zero.
Complexity
The dominant costs are the correlated EXISTS subquery (effectively a semi-join between cohort_users and events, one lookup per candidate user) and the final aggregation. With an index on events(user_id, event_type, occurred_at), each user's existence check is a narrow index range scan, so the query scales roughly linearly in the number of users in the two 60-day windows plus their associated purchase events, rather than the full size of either table.
Edge cases
- Zero pre-period conversions: the query returns
NULLforrelative_uplift_fractionrather than dividing by zero, since a relative change from a 0% baseline is undefined. - Boundary date: a user created exactly on the change date is placed in the
postcohort by the>=comparison; state this rule explicitly if the business intends the opposite. - Multiple purchases per user:
EXISTScounts a user once regardless of how many qualifying purchases they made, which is correct for a conversion-rate metric but would need a different query (counting rows, not distinct converting users) if the goal were purchase volume instead. - Timezone consistency:
created_atandoccurred_atmust be in the same timezone before casting todate, or users near a day boundary can be misclassified.
You're asked to create a Standard Operating Procedure (SOP) for the monthly executive reporting process that includes data extraction, validation, visualization, and distribution. Describe the sections your SOP will contain, how you will enforce version control and approvals, and how you'll design it so a new hire can execute the process with minimal supervision. Include sample fields you would include in the header of each SOP document.
Sample Answer
Direct answer
Separate two things: a reusable standard operating procedure (SOP) template that could apply to any process (metadata, preconditions, validation checks, change history) and the specific step-by-step content for this one process (extract, validate, visualize, distribute). Write the template's shape once, then fill it in per process, so the next SOP takes a fraction of the effort.
Structured elaboration
Generic reusable SOP template, not specific to this process:
- Metadata block: SOP title, SOP ID, version, owner, approver(s), effective date, next review date, related systems or artifacts, confidentiality level.
- Preconditions block: access and credentials required, upstream dependencies (systems that must have already completed their own processing), and the assumed starting state before step 1 can run.
- Procedure block: numbered steps, each naming its input, the action taken, and the expected output.
- Validation checks block: for each step or checkpoint, the specific check that proves the step succeeded (a row count, a checksum, a reconciliation total, a tolerance threshold) and what to do if it fails.
- Escalation block: who to contact and the SLA (service-level agreement) for a fix.
- Change history block: a table logging every revision (date, version, author, summary of the change, approver), so the SOP's own evolution is as auditable as the process it describes.
Applied to monthly executive reporting, filling in that same template: purpose and scope covers the monthly cadence and the dashboards in scope; roles name the owner (a Business Intelligence, BI, Analyst), data stewards, approvers, and the distribution list; preconditions are source-system access, completed upstream refreshes, and a finalized prior-month close; the procedure runs data extraction, data validation, transformation and refresh, dashboard update, report packaging, and distribution; validation checks are concrete numeric thresholds at each step (see the worked example); escalation names a contact and an SLA; change history is logged per the template above.
Version control and approvals. Store the SOP and report source files in a controlled repository (Git or a versioned SharePoint site) using semantic versioning (vMAJOR.MINOR.DATE). Require a change request with two named approvers, a BI lead and a data steward, and stamp the approval (name, role, date, effective version) directly in the SOP.
Designing for a new hire to run it with minimal supervision. A one-page checklist at the top with estimated time per step; annotated screenshots and copy-paste-ready scripts or queries; a "day-of" checklist naming the expected output and acceptance criteria for each step; a short walkthrough recording and a sandbox dataset for practice; and a decision tree for the handful of anomalies that come up most often.
Sample header fields for every SOP document: SOP Title, SOP ID, Version, Effective Date, Owner, Reviewer(s), Approver, Next Review Date, Related Systems/Artifacts, Confidentiality Level, Escalation Contact, and a link to the Change History log.
Worked example
Concrete validation check: last month's extract returned 48,320 rows; this month's returns 44,100. That is a drop of 48,320 - 44,100 = 4,220 rows, or 4,220 / 48,320 = 8.7%. If the standard tolerance for this step is 5%, an 8.7% drop exceeds it and the pipeline halts for review rather than silently publishing a report built on an incomplete extract, exactly the kind of failure the validation-checks block exists to catch before it reaches an executive audience.
Trade-offs and pitfalls
A template that stays too generic doesn't actually save the new hire time; the validation thresholds and escalation contacts have to be filled in with real numbers and names for this specific process, not left as placeholders. Version control without an enforced approval gate is just a filename convention, if anyone can edit and republish without the two-approver sign-off, the "controlled" repository isn't controlling anything. And an SOP that never gets reviewed on its own cadence drifts quietly from the real process until a new hire follows stale instructions and produces a wrong report, which is why the change-history block and the next-review-date field both matter as much as the procedure itself.
Design an experiment to quantify time savings and error reduction from introducing a new SOP versus the historical process. Include experimental design (matched cohorts vs time series), sample size considerations, metrics to collect, statistical tests to use, and how to control for confounders.
Sample Answer
Direct answer
Prefer a randomized matched-cohort test (operators or work items randomly assigned to the new standard operating procedure, or SOP, versus the historical process) when a parallel rollout is feasible, since randomization is the strongest tool for ruling out confounders. When it is not feasible, fall back to an interrupted time series with enough pre- and post-periods to model the trend. Either way, size the sample from the actual baseline variance before running the test, not after, and pre-register the primary metric and decision rule so the analysis cannot be adjusted after seeing the result.
Structured elaboration
Design choice: randomized matched cohort is the default because randomization balances both known and unknown confounders; interrupted time series is the fallback when the SOP has to roll out to everyone at once, and it substitutes a longer observation window plus explicit trend modeling for randomization's balancing effect.
Sample size: derive it from the actual baseline mean and variance for the continuous metric (task time) and the actual baseline rate for the binary metric (error), using standard power formulas, not a round number picked by convention.
Metrics: primary outcomes are mean task time and error rate; secondary outcomes are variance of task time, rework time, and operator-reported friction.
Statistical analysis: compare means with a two-sample test or a mixed-effects regression that adjusts for covariates and operator-level random effects; compare error rates with a proportion test or logistic regression; for an interrupted time series, use segmented regression that explicitly models the level and slope change at the intervention point.
Confounder control: randomization and blocking by shift, experience, and task type where a randomized design is possible; covariate adjustment and balance checks where it is not.
Worked example
Sample size for the continuous metric (task time): assume a baseline mean of 20 minutes with a standard deviation of 8 minutes, and the team wants to reliably detect a 10% reduction (2 minutes) at the standard α=0.05, power =0.80. The two-sample sample-size formula is
n=Δ22σ2(zα/2+zβ)2=222×82×(1.96+0.84)2≈251 per armSample size for the binary metric (error rate): assume a baseline error rate of 5%, wanting to detect a drop to 3% (a 2 percentage-point absolute reduction). The two-proportion formula is
n=(p1−p2)2(zα/2+zβ)2[p1(1−p1)+p2(1−p2)]=0.0227.84×[0.05×0.95+0.03×0.97]≈1,502 per armBecause 1,502 tasks per arm for the error-rate metric is a much larger requirement than 251 per arm for task time, the error-rate metric is the one that sets the real study duration: if the process handles roughly 100 relevant tasks per operator-week across the operators available for the test, the team should plan the study length around reaching about 1,500 tasks per arm for the error-rate comparison, then check that task-time precision easily clears its own smaller requirement along the way.
Confounder handling: randomize within blocks defined by shift, experience tier, and task type so the two arms start balanced; if a fully randomized rollout is not possible, use an interrupted time series with at least 12 pre-intervention and 12 post-intervention periods and a segmented regression that estimates the change in level and slope at the cutover, with autocorrelation-robust standard errors since consecutive periods are not independent.
Trade-offs and pitfalls
- The error-rate metric almost always needs a much larger sample than the time metric, because detecting a small change in a proportion close to zero is statistically harder than detecting the same relative change in a roughly normal continuous measure. Size the study around the harder metric, not the easier one, or the test will be underpowered for the outcome that usually matters most operationally.
- Comparing raw means across operators without an operator-level random effect understates uncertainty, since observations from the same operator are correlated with each other; a mixed-effects model (or clustering standard errors by operator) is not optional once each operator contributes many tasks.
- If several secondary metrics are tested alongside the primary one, correcting for the resulting multiple comparisons, for example by controlling the false discovery rate (the expected proportion of false positives among findings called significant), keeps a large batch of secondary tests from producing a spurious "win" by chance.
- Checking results early and stopping as soon as they look favorable inflates the false-positive rate unless a formal sequential design with a pre-planned alpha-spending rule is used; decide the stopping rule before the data start arriving, not after a promising interim look.
In process analysis, when would you choose a SIPOC, a swimlane diagram, or a value stream map? What does each tool help you uncover, and what are the limitations of each when you are trying to diagnose end-to-end inefficiencies?
Sample Answer
When I’d use each tool
- SIPOC is best early, when I need a high-level view of Suppliers, Inputs, Process, Outputs, and Customers. It helps define scope and prevent boundary confusion.
- Swimlane diagrams are best when I need to see ownership, handoffs, and role-based delays across teams.
- Value stream maps are best when I want to quantify waste, especially wait time versus active work, and find where flow breaks down.
What each uncovers
SIPOC shows the big picture but not detailed flow. Swimlanes expose who does what and where work gets stuck between teams. Value stream mapping is strongest for diagnosing end-to-end inefficiency because it highlights process time, queue time, and rework.
Limitations
SIPOC is too coarse for root-cause work. Swimlanes can become cluttered if the process is large. Value stream maps require good data; without timestamps and volumes, they can look precise while still being mostly opinion. In practice, I’d often start with SIPOC, move to swimlanes, and then use a value stream map for the bottleneck analysis.
Worked example: an expense-approval process
- SIPOC row: Supplier = the employee submitting the expense; Input = receipt plus expense report; Process = “approve expense report”; Output = an approved reimbursement request; Customer = Finance/Payroll. One row tells you the process starts with an employee and ends with Payroll, but nothing about who touches it in between, which is exactly SIPOC’s scope and its limitation.
- Swimlane snippet (3 lanes: Employee, Manager, Finance): the report crosses from the Employee lane (submit) into the Manager lane, where it sits unopened for an average of 2.5 days before the manager approves it or kicks it back for a missing receipt, then into the Finance lane, where someone re-keys the approved amount into the payment system (about 15 minutes of manual re-entry per report). The lane crossings make the handoffs and the team-to-team delay visible in a way the SIPOC row can’t.
- VSM segment with numbers (illustrative for this walkthrough): for that same manager-approval step, process time (the manager actually reviewing) is about 5 minutes; queue time (sitting unopened in the inbox) is 2.5 days, or 3,600 minutes. Process-cycle efficiency for that step is 5 / 3,600 ≈ 0.14%. That single number is what tells you the bottleneck is the wait before anyone looks at the report, not the review itself, a diagnosis neither the SIPOC row nor the swimlane alone would have quantified.
Implement a Python script (using pandas) that compares ETL output table to source table to validate row counts and sum of a numeric column per partition and returns a CSV discrepancy report. Describe inputs, key steps (connect, query, compare, report), error handling, and a sample output format. You do not need to write full code, but outline code-level pseudocode and data validations you would perform.
Sample Answer
Direct answer
Run matching partitioned aggregate queries (row count and sum of the numeric column, grouped by partition key) against both the source and the extract-transform-load (ETL) output table, merge the two result sets on the partition key, compute the deltas against an explicit tolerance, and write only the partitions that breach tolerance to a CSV discrepancy report, failing loudly on connection or query errors rather than silently producing an empty report.
Structured elaboration
Approach: aggregate first, compare second. Comparing pre-aggregated counts and sums per partition is far cheaper than a row-by-row diff, and it's the right level of detail for a routine ETL health check; row-level diffing is reserved for investigating a partition that this check already flagged.
Inputs: database connection configs for source and target, the partition column and list of partitions (or date range) to check, the table names and the numeric column to sum, an output path, and a numeric tolerance for the sum comparison.
import pandas as pd
from sqlalchemy import create_engine, text, bindparam
from typing import List
def query_agg(engine, table: str, partition_col: str, metric_col: str, partitions: List):
sql = (
"SELECT " + partition_col + " AS partition, COUNT(*) AS row_count, "
"SUM(" + metric_col + ") AS metric_sum FROM " + table +
" WHERE " + partition_col + " IN :partitions GROUP BY " + partition_col
)
stmt = text(sql).bindparams(bindparam("partitions", expanding=True))
return pd.read_sql(stmt, engine, params={"partitions": tuple(partitions)})
source_engine = create_engine(source_conn_str)
target_engine = create_engine(target_conn_str)
src = query_agg(source_engine, source_table, partition_col, metric_col, partitions)
tgt = query_agg(target_engine, target_table, partition_col, metric_col, partitions)
df = src.merge(tgt, on="partition", how="outer", suffixes=("_src", "_tgt"))
df["row_diff"] = df["row_count_tgt"].fillna(0) - df["row_count_src"].fillna(0)
df["metric_diff"] = df["metric_sum_tgt"].fillna(0) - df["metric_sum_src"].fillna(0)
df["metric_diff_pct"] = df["metric_diff"] / df["metric_sum_src"].replace(0, pd.NA)
discrepancies = df[(df["row_diff"] != 0) | (df["metric_diff"].abs() > tolerance)]
discrepancies.to_csv(output_path, index=False)
Key points: the outer merge is what catches a partition that exists in one table but not the other (it would otherwise silently be missing from an inner merge); the IN :partitions filter needs SQLAlchemy's expanding=True bind parameter to bind a Python list into the SQL IN clause correctly, a plain scalar bind parameter will not expand a list for most drivers.
Error handling: wrap the query calls, not the whole script, so a failure is attributed to a specific table instead of a generic traceback, and fail loudly (log, then re-raise or exit non-zero) instead of letting a partial result silently become an empty or truncated CSV, which would read as "no discrepancies found" and is worse than no report at all.
import logging
import sys
logger = logging.getLogger("etl_discrepancy_check")
def safe_query_agg(engine, table, partition_col, metric_col, partitions):
try:
return query_agg(engine, table, partition_col, metric_col, partitions)
except Exception as exc:
logger.error("Failed to query %s for partitions %s: %s", table, partitions, exc)
raise
try:
src = safe_query_agg(source_engine, source_table, partition_col, metric_col, partitions)
tgt = safe_query_agg(target_engine, target_table, partition_col, metric_col, partitions)
except Exception:
# A connection failure, an auth error, or a query timeout all land here. Re-raising
# (or sys.exit(1) if this runs as a scheduled job) stops the script before it ever
# writes a CSV, rather than writing an empty or partial discrepancy report that a
# downstream consumer would read as a clean pass.
sys.exit(1)
if src.empty or tgt.empty:
# An empty aggregate result for a partition list that should exist is itself a
# signal, e.g. a table rename or a wrong environment's connection string, not a
# legitimate "zero discrepancies" outcome, so it is logged and treated as a failure
# rather than silently producing a report with fewer rows than expected.
logger.error("Empty aggregate result: source rows=%d, target rows=%d", len(src), len(tgt))
sys.exit(1)
Complexity: each aggregate query scans its table once, O(rows) per table (less with a partition-pruning index on the partition column), returning only P rows (one per partition) rather than the full row count; the pandas merge and comparison then operate on those P rows, not on the underlying millions of source rows, which is what keeps this cheap enough to run on every ETL cycle.
Edge cases:
- Two compensating errors, one row too high by X and another too low by X in the same partition, cancel out in the sum and pass this check even though the data is wrong; this is a structural blind spot of aggregate-only validation, not a bug to fix in this script.
- Floating-point or decimal-precision differences can produce a tiny nonzero
metric_diffthat is rounding noise, not a real discrepancy; the tolerance threshold exists specifically to absorb that. - A partition present in one table and absent in the other shows up as NaN after the outer merge, which
fillna(0)turns into a full row-count or metric-sum discrepancy, exactly the behavior wanted.
Worked example
For partition 2025-11-01: source row_count = 10,000, target row_count = 9,990, so row_diff = 9,990 - 10,000 = -10. Source metric_sum = 123,456.78, target metric_sum = 123,400.00, so metric_diff = 123,400.00 - 123,456.78 = -56.78, and:
Sample CSV row: 2025-11-01, 10000, 9990, -10, 123456.78, 123400.00, -56.78, -0.00046, "row_count_mismatch; metric within tolerance". If tolerance is set at 0.1% of the source sum, this partition's dollar delta passes but its row-count delta of -10 does not, so it still appears in the discrepancy report, flagged specifically as a row-count mismatch rather than a metric mismatch, since those point to different failure modes (dropped rows versus a value transformation bug).
Trade-offs and pitfalls
- Aggregate validation is cheap and catches most real ETL bugs, but it structurally cannot catch compensating errors; a periodic (not every-run) row-level or hash-based sample check is worth the extra cost to close that gap.
- A tolerance set too tight generates noise from ordinary floating-point rounding; set too loose, it hides real small-scale data loss. Deriving the tolerance from the metric column's actual precision (e.g. cents) rather than picking a round number is more defensible.
- Scheduling this on every ETL run versus periodically is a cost-versus-latency trade-off: running it every cycle catches problems immediately but adds query load to production source tables on every run.
Unlock Full Question Bank
Get access to all 43 Process Analysis and Improvement interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.