Pricing
Free to start. RM 25/mo for the full bank.
Cancel anytime — most people subscribe for one hiring cycle, not forever.
Free
RM 0
- ●46 free SQL questions across every difficulty
- ●All 29 SQL lesson chapters, with runnable playgrounds
- ●The whole Excel track — 15 chapters and graded practice
- ●Unlimited practice and grading — nothing time-boxed
- ●AI coach, 3 sessions/day — reads your actual query and tells you what's wrong, never the answer
Sprint
RM 49
One-time charge, full Basic access for 90 days — no subscription, nothing to cancel
Monthly
RM 25RM 20/mo
founding rate, applied automatically at checkout, locked in for as long as you stay subscribed — no code to type
Cancel anytime from your account — no email, no phone call.
Everything in Basic
- The Data Analyst track, start to finish — Basic clears every locked level, not just the free preview
- ●AI coach at 30 sessions/day — work through a whole session's worth of stuck queries without hitting a wall
- ●Free gives you the answer. Basic teaches you why — and asks the second question, the one a real interview asks next.
- ●A dashboard and review queue that get more useful the longer you use the site — weakest category, what to practise next, what you got wrong and never came back to
- ●The full question bank unlocked, plus the take-home round — every SEA and global-MNC question, every locked pack, solutions and grading included
- ●Data Lab (beta) — query performance on a ~300,000-row warehouse, no other practice site goes past a handful of rows. Under construction and headed to Pro, included free with Basic until it's built — access ends when the beta does
- ●New questions and lessons every week, free and paid alike — subscribe once and every future release is already included
This is what "teaches you why" means
One whole walkthrough, unabridged — not a preview.
Every Basic question comes with a walkthrough shaped like this: the wrong-shaped first attempt, why it's wrong, and the reasoning that gets from there to the real answer.
Grab · aggregation
Completed rides by city
Grab's operations team wants to know how many completed rides happened in each city.
From the rides table, return the city and the number of completed rides (status = 'completed') as completed_rides, for every city that has at least one completed ride.
Step 1: Filter before you count
SELECT id, city, status FROM rides WHERE status = 'completed' ORDER BY city, id;| id | city | status |
|---|---|---|
| 6 | Johor Bahru | completed |
| 7 | Johor Bahru | completed |
| 8 | Johor Bahru | completed |
| 1 | Kuala Lumpur | completed |
| 2 | Kuala Lumpur | completed |
| 4 | Penang | completed |
Nine rides in, six out. The three cancellations — including Ipoh's only ride — are gone before anything is counted. Ipoh dropping out entirely is not an accident to fix later; it's the correct answer to "every city that has at least one completed ride."
Step 2: Group the survivors and count
SELECT city, COUNT(*) AS completed_rides
FROM rides
WHERE status = 'completed'
GROUP BY city;| city | completed_rides |
|---|---|
| Johor Bahru | 3 |
| Kuala Lumpur | 2 |
| Penang | 1 |
GROUP BY city folds the six rows into three buckets; COUNT(*) runs once inside each bucket.
Step 3: Why WHERE and not HAVING
Both filter, but they run at different times. WHERE runs before grouping, on individual rows. HAVING runs after, on the groups — so it can only see things that survived aggregation.
A row-level condition like status = 'completed' belongs in WHERE. A group-level one like "only cities with 3 or more completed rides" belongs in HAVING, because the count doesn't exist until the groups do. See Query execution order and HAVING.
Step 4: The two wrong answers
Dropping the filter. No error, four rows, and every single number is wrong:
SELECT city, COUNT(*) AS completed_rides FROM rides GROUP BY city;| city | completed_rides |
|---|---|
| Ipoh | 1 |
| Johor Bahru | 3 |
| Kuala Lumpur | 3 |
| Penang | 2 |
Johor Bahru still reads 3, which is right by coincidence — all three of its rides completed. That coincidence is what lets this mistake survive a glance.
Moving the filter inside the aggregate. Postgres supports FILTER, and it counts correctly, but it changes which cities appear:
SELECT city, COUNT(*) FILTER (WHERE status = 'completed') AS completed_rides
FROM rides
GROUP BY city;| city | completed_rides |
|---|---|
| Ipoh | 0 |
| Johor Bahru | 3 |
| Kuala Lumpur | 2 |
| Penang | 1 |
The three real counts match — but Ipoh is back with a zero, because grouping happened before the filter and Ipoh's cancelled ride was still there to make a group. That's the whole difference between the two clauses: WHERE decides which rows exist, FILTER only decides what gets counted. Neither is wrong in general; here the prompt asks for cities with at least one completed ride, so WHERE is the one that answers the question.
Who's behind this
mahir_data is built and run by a single engineer, in the open, for the SEA hiring market it's themed around — Grab, Shopee, Maybank-style scenarios, not generic textbook tables. New questions and lessons ship every week.
FAQ
- What happens when I cancel?
- Your plan reverts to free at the end of the current billing period — you keep access to everything Basic unlocked until then, and everything free after.
- Do I need to install anything?
- No. Every question runs against a real Postgres engine (PGlite) inside your browser — no signup required to try it, nothing sent to a server to grade a query.
- Is my SQL sent to a server?
- Only if you ask the AI coach for feedback. Running and grading a query happens entirely client-side; your query text never leaves the browser unless you click "Ask the coach."
- Do you keep adding questions?
- Yes — new questions and lessons ship on a weekly cadence, free and paid alike, and anything added after you subscribe is included at no extra cost.