HR Architect Coding Problems
Ten problems tailored to this role. Date math, identity dedup, idempotency under retry, EIB-shaped transforms — the patterns that show up in production HR work.
How to approach each problem out loud
- Restate: "Just to confirm — you want me to..."
- Constraints: input size, duplicates, missing fields, time-zone, effective dates?
- Example: walk a small case
- Brute force: mention it
- Pattern: hash map? sort + sweep? two-pointer? date arithmetic?
- Complexity: state it
- Code with narration
- Test: walk through your example
- Tradeoffs: edges, what you'd add for production
1Deduplicate workers across systems
IdentityHash map
Prompt: You're given lists of workers from Workday, Ashby, and Okta. Each has name and email. Some workers appear in multiple systems. Some have multiple emails (work, personal). Some emails are typo'd. Produce a single list with one row per logical worker. Decide on a deterministic tiebreak.
Show solution
Approach
Normalize emails (lowercase, strip), build a union-find by email. For typos, accept that perfect identity dedup needs a stronger key (govt ID hash). Document the limit.
from collections import defaultdict
class UnionFind:
def __init__(self):
self.parent = {}
def find(self, x):
self.parent.setdefault(x, x)
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]]
x = self.parent[x]
return x
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra != rb:
self.parent[ra] = rb
def dedup_workers(*sources: list[dict]) -> list[dict]:
uf = UnionFind()
record_by_email: dict[str, list[dict]] = defaultdict(list)
# Step 1: normalize and index
for source in sources:
for w in source:
for raw_email in (w.get("emails") or [w.get("email")]):
if not raw_email:
continue
e = raw_email.strip().lower()
record_by_email[e].append(w)
uf.union(e, e) # ensures presence
# Step 2: connect any two emails that appear on the same record
for source in sources:
for w in source:
emails = [e.strip().lower() for e in (w.get("emails") or [w["email"]]) if e]
for e in emails[1:]:
uf.union(emails[0], e)
# Step 3: group by root
groups: dict[str, list[dict]] = defaultdict(list)
for email, records in record_by_email.items():
groups[uf.find(email)].extend(records)
# Step 4: collapse each group deterministically (prefer Workday source)
result = []
for root, records in groups.items():
records.sort(key=lambda r: (r.get("source") != "workday", r.get("worker_id") or ""))
canonical = records[0]
canonical["aliases"] = sorted({r.get("source") for r in records})
result.append(canonical)
return result
Drill notes
- Complexity: O(N·α(N)) for the union-find passes.
- Edge: workers with no email → can't join; surface to a manual list.
- Production: stronger identity key (govt-ID hash, DOB hash) needed to dedup across email-style aliases.
2Headcount on a date
Filter
Prompt: Given a list of workers with hire_date and optional termination_date, return headcount as of a target date. Handle the inclusive-end convention.
Show solution
from datetime import date
def headcount_on(workers: list[dict], as_of: date, term_inclusive: bool = True) -> int:
"""Count workers active on `as_of`.
term_inclusive=True means termination_date == as_of still counts as active
(the standard convention: last day worked is paid).
"""
n = 0
for w in workers:
hd = w["hire_date"]
td = w.get("termination_date")
if hd > as_of:
continue
if td is None:
n += 1
elif term_inclusive and td >= as_of:
n += 1
elif not term_inclusive and td > as_of:
n += 1
return n
Complexity: O(n). State the inclusive-end choice explicitly in your answer.
3Attrition rate
Arithmetic
Prompt: Given workers and a year, compute annualized attrition rate. Define your denominator (average of start and end headcount).
Show solution
from datetime import date
def attrition_rate(workers: list[dict], year: int) -> dict:
start = date(year, 1, 1)
end = date(year, 12, 31)
start_hc = headcount_on(workers, start)
end_hc = headcount_on(workers, end)
terms = sum(
1 for w in workers
if w.get("termination_date")
and start <= w["termination_date"] <= end
)
avg_hc = (start_hc + end_hc) / 2
return {
"terminations": terms,
"start_headcount": start_hc,
"end_headcount": end_hc,
"avg_headcount": avg_hc,
"attrition_rate": terms / avg_hc if avg_hc else None,
}
Discuss alternative denominators (start-only, including new hires). Senior signal: "There's no universal definition — I'd confirm convention with People Analytics before reporting."
4Payroll-period boundary detection
Date math
Prompt: Given a country's payroll cycle (monthly with cutoff 5 business days before end of month), a proposed effective date, and today's date, return: which payroll cycle this lands in, and whether we're inside the lock window.
Show solution
from datetime import date, timedelta
from calendar import monthrange
def is_business_day(d: date) -> bool:
return d.weekday() < 5 # 0=Mon, 4=Fri (no holiday calendar here; in prod, use one)
def shift_business_days(d: date, days_back: int) -> date:
"""Return the date that is `days_back` business days before d."""
cur = d
n = days_back
while n > 0:
cur -= timedelta(days=1)
if is_business_day(cur):
n -= 1
return cur
def payroll_window(today: date, effective: date,
country: str = "US",
lock_business_days_before_eom: int = 5) -> dict:
"""For a monthly payroll cycle with cutoff `lock_business_days_before_eom`
business days before the end of the month."""
target_month = effective.month
target_year = effective.year
last_day = date(target_year, target_month, monthrange(target_year, target_month)[1])
cutoff = shift_business_days(last_day, lock_business_days_before_eom)
locked = today > cutoff
cycle = f"{target_year}-{target_month:02d}"
return {
"cycle": cycle,
"cutoff": cutoff,
"locked": locked,
"days_to_cutoff": (cutoff - today).days,
}
Discuss: production version uses a real holiday calendar per country; this is the skeleton.
5Workday-EIB-style flat-file transformation
Transform
Prompt: Given a list of proposed job-change dicts, emit an EIB-ready CSV with exact column order, mandatory fields, ISO-date strings, and one comment column that concatenates rationale and source-run-id for traceability.
Show solution
import csv, io
from datetime import date
EIB_COLUMNS = [
"Employee_ID", "Effective_Date", "New_Position_ID",
"New_Job_Profile", "New_Supervisor_ID", "New_Location",
"New_FTE", "Reason_Code", "Business_Process_Comment",
]
REQUIRED = {"Employee_ID", "Effective_Date", "Reason_Code"}
def to_eib_csv(proposals: list[dict], run_id: str) -> str:
buf = io.StringIO()
writer = csv.DictWriter(buf, fieldnames=EIB_COLUMNS, extrasaction="ignore")
writer.writeheader()
for p in proposals:
row = {
"Employee_ID": p["employee_id"],
"Effective_Date": p["effective_date"].isoformat()
if isinstance(p["effective_date"], date) else p["effective_date"],
"New_Position_ID": p.get("new_position_id", ""),
"New_Job_Profile": p.get("new_job_profile", ""),
"New_Supervisor_ID": p.get("new_supervisor_id", ""),
"New_Location": p.get("new_location", ""),
"New_FTE": f"{p['new_fte']:.4f}" if p.get("new_fte") is not None else "",
"Reason_Code": p["reason_code"],
"Business_Process_Comment": f"{p.get('rationale','')} [run:{run_id} proposal:{p['proposal_id']}]",
}
missing = [k for k in REQUIRED if not row[k]]
if missing:
raise ValueError(f"Missing required EIB fields {missing} for {p}")
writer.writerow(row)
return buf.getvalue()
Discuss: header text and column order are part of the Workday EIB template contract — drifting from either breaks the upload. Production: snapshot-test the output against a canonical EIB header.
6Idempotency-key design for hire-rehire scenarios
Idempotency
Prompt: Design an idempotency-key scheme for "hire" proposals such that (a) retries of the same logical hire collapse to one effect, (b) a rehire of the same person 3 years later does not collapse with the original.
Show solution
The key encodes the intent of this hire event, not just the person:
import hashlib
def hire_idempotency_key(applicant_id: str,
hire_date: str,
position_id: str,
intent_window_days: int = 7) -> str:
"""A hire proposal is the same logical event when:
- same applicant
- same intended position
- hire_date within `intent_window_days` of original
Implementation: bucket the hire_date into `intent_window_days` periods so
near-equal dates collide but distant dates don't.
"""
from datetime import date
hd = date.fromisoformat(hire_date)
bucket = hd.toordinal() // intent_window_days
raw = f"hire:{applicant_id}:{position_id}:bucket={bucket}"
return hashlib.sha256(raw.encode()).hexdigest()[:24]
Why this works:
- Two retries of "hire applicant ASH-1 to position P-99 effective 2026-06-01" produce the same key.
- A retry with hire_date shifted by 2 days (operator edit) usually produces the same key, because the intent did not change. A shift that crosses a bucket boundary produces a different key, so treat the window as a heuristic and decide what an edit should do.
- A rehire 3 years later — same applicant, possibly same position — produces a different bucket, different key. No collision.
- A rehire to a different position is trivially a different key.
Articulating the business intent the key represents (not just "hash the request") is what distinguishes this answer. Mention the failure modes of naïve hashing (any field change creates a new key, defeating retry safety) and over-collapsing (rehire silently no-ops).
7Tenure-aware leave accrual
Date math
Prompt: Compute accrued PTO days for a worker from hire date through today, given: 1.25 days/month for first year, 1.5 days/month thereafter, with a 30-day cap. Pro-rate the month they were hired in.
Show solution
from datetime import date
from calendar import monthrange
def accrued_pto(hire_date: date, as_of: date,
rate_first_year: float = 1.25,
rate_after: float = 1.5,
cap_days: float = 30.0) -> float:
if as_of < hire_date:
return 0.0
total = 0.0
# First (possibly partial) month
first_month_days = monthrange(hire_date.year, hire_date.month)[1]
days_in_first = first_month_days - hire_date.day + 1
fraction = days_in_first / first_month_days
rate = rate_first_year # they're definitely < 1y at hire
total += rate * fraction
cur = date(hire_date.year, hire_date.month, 1)
# Advance to next month
cur = (date(cur.year + (cur.month // 12), (cur.month % 12) + 1, 1))
while cur <= as_of:
# Is this month < 12 months of tenure?
anniv = date(hire_date.year + 1, hire_date.month, hire_date.day)
rate = rate_first_year if cur < anniv else rate_after
# Was this month complete by as_of?
last_day = date(cur.year, cur.month, monthrange(cur.year, cur.month)[1])
if as_of >= last_day:
total += rate
else:
partial = (as_of.day) / monthrange(cur.year, cur.month)[1]
total += rate * partial
# Next month
cur = date(cur.year + (cur.month // 12), (cur.month % 12) + 1, 1)
return min(total, cap_days)
Discuss: real plans have many wrinkles (carryover, jurisdictional minimums, accrual on unpaid leave). State you're solving the named version; production: configurable plan model.
8Org reorg cascade
Graph
Prompt: A supervisory org tree is a forest of parent → children. Given a worker's old manager and new manager, compute every worker whose "skip-level chain" changed.
Show solution
from collections import deque
def descendants(children: dict[str, list[str]], root: str) -> set[str]:
"""All workers under `root` inclusive, BFS."""
seen = {root}
q = deque([root])
while q:
cur = q.popleft()
for c in children.get(cur, []):
if c not in seen:
seen.add(c)
q.append(c)
return seen
def affected_by_reorg(children: dict[str, list[str]],
moving_worker: str) -> set[str]:
"""Every worker whose skip-level chain changed when `moving_worker` (and
everyone reporting under them) moves. That's the subtree rooted at the
moving worker."""
return descendants(children, moving_worker)
Discuss: the "skip-level chain changed" set is the moved subtree, since their direct chain to root changes. If the question is about who reports to whom changes, it's the same subtree minus the moving worker (whose direct manager changes, plus everyone whose skip-level shifts).
9Overlapping leave windows
Sort + sweep
Prompt: Given a list of leave records per worker (start, end, type), detect any overlaps for the same worker. Return overlapping pairs.
Show solution
from collections import defaultdict
def detect_leave_overlaps(leaves: list[dict]) -> list[tuple[dict, dict]]:
by_worker = defaultdict(list)
for l in leaves:
by_worker[l["worker_id"]].append(l)
overlaps = []
for worker_id, lst in by_worker.items():
lst.sort(key=lambda x: (x["start"], x["end"]))
for i in range(len(lst)):
for j in range(i + 1, len(lst)):
# Sorted by start, so once a start passes lst[i]'s end, no later one overlaps it
if lst[j]["start"] > lst[i]["end"]:
break
overlaps.append((lst[i], lst[j]))
return overlaps
Complexity: O(n log n) per worker for the sort, plus the number of overlapping pairs reported. Comparing only neighbours misses a short leave nested inside a long one when another leave sits between them. Edge: same-day adjacent windows; clarify the inclusive or exclusive end-date convention.
10Active-day count over a range
Interval
Prompt: Given a worker's hire and termination dates, count how many days they were active within a given date range [range_start, range_end].
Show solution
from datetime import date
def active_days_in_range(hire: date, term: date | None,
range_start: date, range_end: date) -> int:
if range_end < range_start:
return 0
start = max(hire, range_start)
end = min(term, range_end) if term else range_end
if end < start:
return 0
return (end - start).days + 1 # inclusive both ends
Useful for FTE-weighted headcount, payroll period proration, and accrual calculations.
✓ Check yourself
Answer from memory before opening each one.
-
Workday, Ashby and Okta each list a worker, and one person has a work and a personal email across them. Why does grouping by the first email fail, and what structure fixes it?
Show answer
Grouping by one key splits a person whose records share only a different email, so the same worker appears twice. Union-find joins emails that appear on the same record into one component, so records link transitively. Typos and workers with no email still need a stronger key such as a hashed government ID, or a manual list. See Deduplicate workers across systems.
-
A worker is terminated with a last day of 30 June. Does headcount on 30 June count them, and how do you make sure two analysts get the same number?
Show answer
Under the usual convention, last day worked is paid, so yes, termination_date >= as_of still counts them. The convention varies by organisation, so expose it as an explicit parameter and state it with the figure. See Headcount on a date.
-
A proposed comp change is effective on the 20th, and a month-end cutoff sits five business days before the last day. Describe how the skeleton decides lock, and name two things production adds.
Show answer
It finds the cutoff by stepping back five business days from the month end of the effective date's month and compares today to it. Production adds a per-country holiday calendar and each country's own cycle and cutoff rules, since the skeleton assumes one monthly US-style cycle. See Payroll-period boundary detection.
-
An EIB upload fails because a header reads "New_FTE " with a trailing space. Which test would have caught it, and why is it a contract problem rather than a formatting one?
Show answer
A snapshot test of the generated header row against the canonical template header. The template defines exact column order and names, so any drift breaks the upload on Workday's side. Required-field checks before writing catch missing values separately. See Workday-EIB-style flat-file transformation.
-
An operator edits a hire date by two days after the proposal is created. With week-sized buckets in the key, what can go wrong in each direction?
Show answer
If the edit stays in the same bucket, the key matches and the retry collapses, which is intended. If it crosses a bucket boundary the key changes and a second proposal can commit, so the window is a heuristic. In the other direction, a window that is too wide could swallow a genuine rehire close in time. Decide the rule for edits explicitly, as in the HITL policy. See Idempotency-key design for hire-rehire scenarios.
-
Leaves are A: days 1 to 10, B: days 2 to 3, C: days 5 to 6, for one worker. Which pairs overlap, and why does comparing only each leave with the previous one miss one?
Show answer
A overlaps B and A overlaps C. After sorting, C is compared only with B, which has already ended, so the A and C pair is missed. Compare each leave with later ones until a start passes its end. Also confirm whether end dates are inclusive. See Overlapping leave windows.
Takeaway
- Identity matching across systems needs transitive linking, such as union-find, and an honest statement that email alone has limits.
- Headcount and attrition answers depend on stated conventions, so make termination-date inclusion and the attrition denominator explicit.
- A payroll window function takes a country and calendar, and the skeleton's single monthly cycle is not what production uses.
- EIB output is a contract: exact headers, column order, required fields, and a snapshot test to guard them.
- An idempotency key encodes business intent, so retries collapse and a later rehire does not; bucketed dates are a heuristic with a boundary edge case.
- Interval problems turn on sorting and on clarifying inclusive or exclusive ends, and a neighbour-only check misses nested overlaps.
You can now write the core code; next is where the data comes from and how it flows through pipelines. → Data & Pipelines for HR
Further reading
- datetime — Python documentation. Dates, deltas and ordinals used in the date-math problems.
- calendar — Python documentation. monthrange and weekday handling for payroll periods and accrual.
- csv — Python documentation. DictWriter, field order and quoting for EIB-shaped output.
- hashlib — Python documentation. Hashing used to build stable idempotency keys.
- collections — Python documentation. defaultdict and deque used in the grouping and graph solutions.