HR Attrition & Workforce Dashboard
This project uses synthetic data. No real people or companies are represented. A synthetic 50-person workforce, cleaned with SQL, summarised in Excel and explored in an interactive dashboard.
- Cleaned dataset
- Data dictionary
- SQL cleaning script
- Excel summary (live formulas)
- Interactive dashboard
- One-page insights memo
A fictional company, Vantara Global Services, lost 10 of its 50 employees in 12 months and wants to know where and why, using data it can trust.
Self-initiated portfolio project. All 50 records are synthetic. No real person or company is represented.
I checked the raw records, cleaned them with SQL and validated the output against the Excel summary. I use the same cleaned records for every dashboard calculation and keep missing performance ratings as NULL.
12 months to 30 September 2026
Workforce overview
90-Second Guided Tour
Guided tour (text). Not a video.
Headcount
50
Leavers
10
Attrition %
20.0%
Average engagement (1–5)
3.67
Average performance rating
3.31
n = people in each group. Attrition = leavers ÷ group headcount; this is a snapshot ratio, not an average-headcount annual rate.
Data-quality story
51 raw rows → 50 clean rows.
Duplicate row removed (1)
Department labels standardised (3)
Location labels standardised (3)
Date formats unified (3)
Gender codes standardised (1)
Blank performance ratings kept as NULL, not imputed (2)
SQL cleaning script
-- HR attrition dataset: raw -> clean (SQLite syntax, minor changes needed for MySQL or PostgreSQL)
-- Source: employees_raw (51 rows, SYNTHETIC data, snapshot date 2026-09-30)
DROP TABLE IF EXISTS employees_clean;
CREATE TABLE employees_clean AS
WITH dedup AS ( -- 1) remove exact duplicate employee_id rows
SELECT *, ROW_NUMBER() OVER (PARTITION BY employee_id ORDER BY rowid) AS rn
FROM employees_raw
)
SELECT
employee_id,
CASE -- 2) standardise department labels
WHEN LOWER(TRIM(department)) IN ('eng','engineering') THEN 'Engineering'
WHEN LOWER(TRIM(department)) = 'hr' THEN 'HR'
ELSE TRIM(department)
END AS department,
job_title,
job_level,
CASE LOWER(TRIM(location)) -- 3) standardise location labels
WHEN 'bangalore' THEN 'Bengaluru'
ELSE UPPER(SUBSTR(TRIM(location),1,1)) || LOWER(SUBSTR(TRIM(location),2))
END AS location,
work_mode,
CASE -- 4) one date format (YYYY-MM-DD)
WHEN hire_date LIKE '__-__-____'
THEN SUBSTR(hire_date,7,4) || '-' || SUBSTR(hire_date,4,2) || '-' || SUBSTR(hire_date,1,2)
ELSE hire_date
END AS hire_date,
tenure_months,
age_band,
CASE WHEN gender IN ('F','Female') THEN 'F' -- 5) standardise gender codes
WHEN gender IN ('M','Male') THEN 'M' END AS gender,
annual_ctc_inr,
NULLIF(performance_rating,'') AS performance_rating, -- 6) blanks -> NULL (not imputed)
engagement_score,
promoted_last_24m,
overtime_flag,
status,
NULLIF(exit_date,'') AS exit_date,
NULLIF(exit_reason,'') AS exit_reason
FROM dedup
WHERE rn = 1;
-- Validation queries
SELECT COUNT(*) AS rows_after_cleaning FROM employees_clean; -- expect 50
SELECT COUNT(*) AS rows_before FROM employees_raw; -- 51
SELECT COUNT(*) AS missing_ratings FROM employees_clean WHERE performance_rating IS NULL; -- 2
SELECT department, COUNT(*) FROM employees_clean GROUP BY department;
What each step does
- Dedupe with ROW_NUMBER: keep the first row per employee_id. This dataset has one exact duplicate; conflicting records would need a separate review.
- Standardise text with CASE and TRIM: reconcile department labels, location names and gender codes.
- Unify dates: convert DD-MM-YYYY to YYYY-MM-DD, keeping already standard dates.
- Use NULLIF for blanks: preserve missing ratings and empty exit fields as NULL rather than inventing values.
Validation queries
| Query | Expected result |
|---|---|
SELECT COUNT(*) FROM employees_clean; | 50 rows after cleaning |
SELECT COUNT(*) FROM employees_raw; | 51 rows before cleaning |
SELECT COUNT(*) FROM employees_clean WHERE performance_rating IS NULL; | 2 missing ratings |
Data dictionary · 18 columns
| Column | Type | Description | Example |
|---|---|---|---|
employee_id | text | Unique synthetic ID (E001-E050). No real person. | E017 |
department | text | Engineering, Customer Support, Operations, Sales, Finance, HR (standardised in cleaning). | Engineering |
job_title | text | Title implied by department and level. | Senior Engineer |
job_level | text | L1 (entry) to L5 (senior manager). | L3 |
location | text | Bengaluru, Hyderabad, Chennai, Kochi (standardised). | Kochi |
work_mode | text | Hybrid, On-site or Remote. | Hybrid |
hire_date | date YYYY-MM-DD | Date of joining (mixed formats fixed in cleaning). | 2024-05-02 |
tenure_months | integer | Months from hire_date to exit_date (leavers) or to snapshot date 2026-09-30 (active). | 29 |
age_band | text | 22-25, 26-30, 31-35, 36-40, 41+. | 26-30 |
gender | text | F or M (standardised). Used only to demonstrate an internal-equity check. | F |
annual_ctc_inr | integer | Synthetic annual cost-to-company in INR. | 363000 |
performance_rating | integer 1-5 | Latest rating. 2 blanks kept as NULL (not imputed). | 4 |
engagement_score | decimal 1.0-5.0 | Synthetic pulse engagement score. | 3.2 |
promoted_last_24m | Y/N | Promoted in the 24 months before snapshot. | N |
overtime_flag | Y/N | Regular overtime worked (Y/N). | Y |
status | text | Active or Left (left between 2025-10-01 and 2026-09-30). | Left |
exit_date | date YYYY-MM-DD | Blank for active employees. | 2026-07-21 |
exit_reason | text | Blank for active employees. | Career growth |
Source files
One-page insights memo
10 of 50 employees (20%) left in the 12 months to 30 September 2026.
What the data shows
- 01
Low engagement is the strongest signal. 7 of 12 employees scoring below 3.2 left (58%), against 3 of 38 (8%) of everyone else.
- 02
Stalled careers. 9 of 38 employees not promoted in the last 24 months left (24%), against 1 of 12 who were promoted (8%).
- 03
Overtime. 5 of 19 with regular overtime left (26%), against 5 of 31 without (16%).
- 04
Reasons. Career growth (5) and manager relationship (4) account for 9 of the 10 exits. No leaver cited compensation.
- 05
Where. Engineering lost the most people (4 of 16). Finance had the highest rate (2 of 6), on a very small base.
- 06
Tenure. All 3 employees with under 12 months of service left (too few to generalise). Attrition was lowest at 12–47 months (3 of 28) and higher at 48+ months (4 of 19).
Recommendations
- Run stay interviews within 30 days for anyone scoring below 3.2.
- Add a promotion-readiness review at 18 months with a clear development path.
- Flag employees with overtime in two or more consecutive months for a workload review.
- Review one-to-one frequency and run upward feedback for managers named in exit interviews.
- Add a structured 90-day check-in for every new joiner.
Limits
Synthetic data, n = 50. These are associations, not causes. Groups are small. Engagement was measured at one point in time, and leavers may have scored lower at exit. The pay table demonstrates a method and supports no conclusion. In a real company I would repeat this with a full year of data and test significance.
Synthetic data, n = 50. These are associations, not causes. Groups are small. Engagement was measured at one point in time, and leavers may have scored lower at exit. The pay table demonstrates a method and supports no conclusion. In a real company I would repeat this with a full year of data and test significance.
In a live setting I would repeat this with a full year of real data, test significance, and track the effect of the recommendations on attrition each quarter.