Day 33: SQL Joins, GROUP BY & HAVING
✅ Today you will learn
- Aggregate functions and GROUP BY
- HAVING vs WHERE
- INNER, LEFT, RIGHT/FULL joins
- Self-join, UNION and sub-queries
- Translate between SQL and pandas
🏢 In the real world
The HR head asks: “Show me head-count and average salary per department and office location – and list anyone not mapped to a department.” Finance asks: “Which cities crossed ₹6 lakh in prepaid revenue?” Both are GROUP BY + JOIN questions – the bread and butter of every SQL interview.
import sqlite3
import pandas as pd
con = sqlite3.connect("bazaar.db")
pd.read_csv("sales_data.csv").to_sql("sales", con, if_exists="replace", index=False)
pd.read_csv("employees.csv").to_sql("employees", con, if_exists="replace", index=False)
pd.read_csv("departments.csv").to_sql("departments", con, if_exists="replace", index=False)
print([r[0] for r in con.execute("SELECT name FROM sqlite_master WHERE type = 'table'")])Aggregates and GROUP BY
import sqlite3
import pandas as pd
con = sqlite3.connect("bazaar.db")
q = lambda sql: pd.read_sql(sql, con)
print(q("""
SELECT COUNT(*) AS orders,
COUNT(rating) AS rated_orders,
COUNT(DISTINCT city) AS cities,
ROUND(SUM(quantity * unit_price * (1 - discount))) AS revenue,
ROUND(AVG(rating), 2) AS avg_rating,
MIN(order_date) AS first_day, MAX(order_date) AS last_day
FROM sales
"""))import sqlite3
import pandas as pd
con = sqlite3.connect("bazaar.db")
q = lambda sql: pd.read_sql(sql, con)
print(q("""
SELECT region,
COUNT(*) AS orders,
ROUND(SUM(quantity * unit_price * (1 - discount)) / 100000.0, 2) AS revenue_lakh,
ROUND(AVG(quantity * unit_price * (1 - discount))) AS aov
FROM sales
GROUP BY region
ORDER BY revenue_lakh DESC
"""))Every column in SELECT must either be in GROUP BY or inside an aggregate (SUM, AVG, COUNT…). SELECT city, region, SUM(...) ... GROUP BY city is an error in most databases.
HAVING – filter groups
import sqlite3
import pandas as pd
con = sqlite3.connect("bazaar.db")
q = lambda sql: pd.read_sql(sql, con)
print(q("""
SELECT city,
ROUND(SUM(quantity * unit_price * (1 - discount)) / 100000.0, 2) AS revenue_lakh
FROM sales
WHERE payment_mode <> 'Cash on Delivery' -- row filter (before grouping)
GROUP BY city
HAVING SUM(quantity * unit_price * (1 - discount)) > 600000 -- group filter
ORDER BY revenue_lakh DESC
"""))JOINs
import sqlite3
import pandas as pd
con = sqlite3.connect("bazaar.db")
q = lambda sql: pd.read_sql(sql, con)
print(q("""
SELECT e.name, e.salary, d.dept_name, d.location
FROM employees AS e
INNER JOIN departments AS d ON e.dept_id = d.dept_id
ORDER BY e.salary DESC
LIMIT 5
"""))
print(q("SELECT COUNT(*) AS inner_rows FROM employees e JOIN departments d ON e.dept_id = d.dept_id"))import sqlite3
import pandas as pd
con = sqlite3.connect("bazaar.db")
q = lambda sql: pd.read_sql(sql, con)
print(q("""
SELECT e.emp_id, e.name, e.dept_id, d.dept_name
FROM employees e
LEFT JOIN departments d ON e.dept_id = d.dept_id
WHERE d.dept_id IS NULL -- employees with no matching department
"""))import sqlite3
import pandas as pd
con = sqlite3.connect("bazaar.db")
q = lambda sql: pd.read_sql(sql, con)
print(q("""
SELECT COALESCE(d.dept_name, 'Unassigned') AS department,
COALESCE(d.location, '-') AS location,
COUNT(e.emp_id) AS headcount,
ROUND(AVG(e.salary)) AS avg_salary
FROM employees e
LEFT JOIN departments d ON e.dept_id = d.dept_id
GROUP BY department, location
ORDER BY headcount DESC
"""))import sqlite3
import pandas as pd
con = sqlite3.connect("bazaar.db")
q = lambda sql: pd.read_sql(sql, con)
print(q("""
SELECT d.dept_name, COUNT(e.emp_id) AS headcount
FROM departments d
LEFT JOIN employees e ON e.dept_id = d.dept_id
GROUP BY d.dept_name
UNION ALL
SELECT 'No department', COUNT(*) FROM employees
WHERE dept_id NOT IN (SELECT dept_id FROM departments)
"""))PostgreSQL and SQLite 3.39+ support FULL OUTER JOIN directly. MySQL does not – combine a LEFT JOIN and a RIGHT JOIN with UNION, as above.
Self-join and sub-queries
import sqlite3
import pandas as pd
con = sqlite3.connect("bazaar.db")
q = lambda sql: pd.read_sql(sql, con)
print(q("""
SELECT a.name AS employee, b.name AS colleague, a.city
FROM employees a
JOIN employees b ON a.city = b.city AND a.emp_id < b.emp_id AND a.dept_id = b.dept_id
WHERE a.city = 'Pune'
"""))import sqlite3
import pandas as pd
con = sqlite3.connect("bazaar.db")
q = lambda sql: pd.read_sql(sql, con)
print(q("""
SELECT name, salary, dept_id
FROM employees e
WHERE salary > (SELECT AVG(salary) FROM employees x WHERE x.dept_id = e.dept_id) -- correlated sub-query
ORDER BY dept_id, salary DESC
LIMIT 8
"""))SQL ↔ pandas cheat sheet
| SQL | pandas |
|---|---|
| SELECT a, b FROM t | df[["a", "b"]] |
| WHERE city = 'Pune' | df[df.city == "Pune"] |
| GROUP BY city … SUM(x) | df.groupby("city")["x"].sum() |
| HAVING SUM(x) > 10 | .loc[lambda s: s > 10] after groupby |
| ORDER BY x DESC LIMIT 5 | df.nlargest(5, "x") |
| LEFT JOIN … ON k | df.merge(other, on="k", how="left") |
| UNION ALL | pd.concat([a, b]) |
| COALESCE(x, 0) | df.x.fillna(0) |
🏅 Job skills unlocked today
Practice tasks
🏋️ Practice – do it yourself
Show solution
import sqlite3
import pandas as pd
con = sqlite3.connect("bazaar.db")
q = lambda sql: pd.read_sql(sql, con)
print(q("""SELECT category, payment_mode, COUNT(*) AS orders, ROUND(SUM(quantity*unit_price*(1-discount))) AS revenue
FROM sales GROUP BY category, payment_mode HAVING COUNT(*) >= 100 ORDER BY revenue DESC"""))Show solution
import sqlite3
import pandas as pd
con = sqlite3.connect("bazaar.db")
q = lambda sql: pd.read_sql(sql, con)
print(q("""SELECT d.location, SUM(e.salary) AS cost, COUNT(*) AS people FROM employees e
JOIN departments d ON e.dept_id = d.dept_id GROUP BY d.location ORDER BY cost DESC"""))Show solution
import sqlite3
import pandas as pd
con = sqlite3.connect("bazaar.db")
q = lambda sql: pd.read_sql(sql, con)
print(q("""SELECT d.dept_name, e.name, e.salary FROM employees e JOIN departments d ON e.dept_id = d.dept_id
WHERE e.salary = (SELECT MAX(salary) FROM employees x WHERE x.dept_id = e.dept_id) ORDER BY e.salary DESC"""))Quick quiz
🧠 Quick Quiz – test yourself
Interview corner
💼 Interview Corner
Explain the types of SQL joins.
INNER returns matching rows from both tables; LEFT returns all rows of the left table plus matches (NULLs otherwise); RIGHT is the mirror; FULL OUTER returns all rows from both; CROSS returns every combination; a self-join joins a table to itself.
Find departments with more than 5 employees.
SELECT dept_id, COUNT(*) FROM employees GROUP BY dept_id HAVING COUNT(*) > 5; – join departments for the name.
Summary
- GROUP BY + aggregates summarise; HAVING filters groups.
- INNER = matches, LEFT = keep all left rows, FULL = everything.
- COALESCE fills NULLs; UNION ALL stacks results.
- Sub-queries compare a row with its group.
Day 34: window functions and CTEs – rankings, running totals and month-over-month growth in SQL.
