1. Lighthouse

Level 2 · Check and summarise headcount and pay in SQL

The request

From Laura Bennett, Head of Finance

“We agreed the plan: headcount, payroll and typical pay by department, then direct reports per manager. The HR export has been hand-edited over the years, so first tell me whether it holds together: anyone listed twice, managers who aren't on the list, salaries that look wrong.”

Your goal

Step 1 of 3. Check the table. Join employees (alias e) to itself (alias m) with a LEFT JOIN on m.employee_id = e.manager_id and return one row: employees (rows), distinct_ids, distinct_names (distinct first_name || ' ' || last_name), no_manager (e.manager_id is NULL), unknown_manager (e.manager_id is not NULL but no manager row was found), min_salary and max_salary.

  • Read from the employees tablenot done yet
  • Show exactly employees distinct_ids distinct_names no_manager unknown_manager min_salary max_salarynot done yet
  • 1 row, sorted as askednot done yet

Step 1 · your query

SQL

Loading the editor…

    ResultPreview of employees · press Run to see yours
    Preview of employees
    employee_idfirst_namelast_namedepartmenttitle
    1CatherineHaleExecutiveChief Executive Officer
    2RobertKimExecutiveChief Operating Officer
    3MeganCollinsSalesHead of Sales
    4ChloeGutierrezSalesAccount Executive

    Mote checks your result

    Press Run. Each row that matches the goal becomes a firefly in your lantern.

    Step 2 · your query

    SQL

    Loading the editor…

      ResultPreview of employees · press Run to see yours
      Preview of employees
      employee_idfirst_namelast_namedepartmenttitle
      1CatherineHaleExecutiveChief Executive Officer
      2RobertKimExecutiveChief Operating Officer
      3MeganCollinsSalesHead of Sales
      4ChloeGutierrezSalesAccount Executive

      Mote checks your result

      Press Run. Each row that matches the goal becomes a firefly in your lantern.

      Step 3 · your query

      SQL

      Loading the editor…

        ResultPreview of employees · press Run to see yours
        Preview of employees
        employee_idfirst_namelast_namedepartmenttitle
        1CatherineHaleExecutiveChief Executive Officer
        2RobertKimExecutiveChief Operating Officer
        3MeganCollinsSalesHead of Sales
        4ChloeGutierrezSalesAccount Executive

        Mote checks your result

        Press Run. Each row that matches the goal becomes a firefly in your lantern.

        Tables

        Harbor & Pine store data, ready to query.

        employees60 rows · 8 columns
        • employee_idinteger
        • first_namevarchar
        • last_namevarchar
        • departmentvarchar
        • titlevarchar
        • manager_idinteger
        • hire_datedate
        • salaryinteger
        First 5 rows of employees
        employee_idfirst_namelast_namedepartmenttitlemanager_idhire_datesalary
        1CatherineHaleExecutiveChief Executive OfficerNULL2016-03-01285000
        2RobertKimExecutiveChief Operating Officer12017-01-09235000
        3MeganCollinsSalesHead of Sales12018-04-16172000
        4ChloeGutierrezSalesAccount Executive32020-01-2172000
        5FrankBellSalesAccount Executive32023-05-2173000
        customers2,018 rows · 9 columns
        • customer_idinteger
        • first_namevarchar
        • last_namevarchar
        • emailvarchar
        • cityvarchar
        • statevarchar
        • signup_datedate
        • segmentvarchar
        • referral_sourcevarchar
        First 5 rows of customers
        customer_idfirst_namelast_nameemailcitystatesignup_datesegmentreferral_source
        1OwenHudsonowen.hudson@icloud.comSan FranciscoCA2022-06-02consumersearch
        2DouglasMendozadmendoza69@gmail.comAtlantaGA2022-06-02consumerNULL
        3HenryBrookshbrooks@lakesidecafe.comLos AngelesCA2022-06-04businessNULL
        4BrianGrahambgraham@bluecedarhomes.comSeattleWA2022-06-06businesspinterest
        5KyleJimenezkjimenez27@yahoo.comIndianapolisIN2022-06-07consumerNULL
        products120 rows · 8 columns
        • product_idinteger
        • namevarchar
        • categoryvarchar
        • subcategoryvarchar
        • unit_pricedecimal
        • unit_costdecimal
        • launched_ondate
        • is_activeboolean
        First 5 rows of products
        product_idnamecategorysubcategoryunit_priceunit_costlaunched_onis_active
        101Vale Velvet LoveseatFurnitureSofas1659.00NULL2023-09-11true
        102Alder Oak Coffee TableFurnitureTables919.00503.502024-05-16true
        103Orchard Rattan Dining ChairFurnitureChairs789.00429.872023-05-27true
        104Upland Oak BookcaseFurnitureStorage429.00214.702022-12-20true
        105Avery Leather Bed FrameFurnitureBeds449.00232.702021-08-31true
        orders12,000 rows · 7 columns
        • order_idinteger
        • customer_idinteger
        • order_datetimestamp
        • statusvarchar
        • channelvarchar
        • shipping_feedecimal
        • coupon_codevarchar
        First 5 rows of orders
        order_idcustomer_idorder_datestatuschannelshipping_feecoupon_code
        10011252023-01-01 20:51:19completedweb0.00NULL
        10021562023-01-02 07:58:46completedweb0.00NULL
        1003222023-01-02 10:25:27completedmarketplace4.99WELCOME10
        1004482023-01-02 11:21:51completedmarketplace4.99WELCOME10
        1005822023-01-02 11:43:36completedweb0.00WELCOME10
        order_items30,338 rows · 6 columns
        • order_item_idinteger
        • order_idinteger
        • product_idinteger
        • quantityinteger
        • unit_pricedecimal
        • discount_pctdecimal
        First 5 rows of order_items
        order_item_idorder_idproduct_idquantityunit_pricediscount_pct
        11001121931.990.00
        21001154311.990.00
        31002154411.990.00
        410021941149.000.00
        51002193219.990.00
        returns1,673 rows · 5 columns
        • return_idinteger
        • order_item_idinteger
        • returned_ondate
        • reasonvarchar
        • refund_amountdecimal
        First 5 rows of returns
        return_idorder_item_idreturned_onreasonrefund_amount
        1142023-01-10damaged83.69
        2422023-01-10wrong_item19.99
        31052023-01-14wrong_item10.99
        4242023-01-16changed_mind26.99
        51262023-01-16late20.99
        web_sessions40,000 rows · 7 columns
        • session_idinteger
        • customer_idinteger
        • started_attimestamp
        • devicevarchar
        • sourcevarchar
        • pages_viewedinteger
        • convertedboolean
        First 5 rows of web_sessions
        session_idcustomer_idstarted_atdevicesourcepages_viewedconverted
        1NULL2023-01-01 23:02:24mobilepaid1false
        2182023-01-01 15:07:49desktoppaid1false
        3NULL2023-01-01 16:55:46desktopdirect7false
        4NULL2023-01-01 12:43:28mobileorganic3false
        5NULL2023-01-01 13:06:21mobilesocial2false
        marketing_spend3,276 rows · 3 columns
        • spend_datedate
        • channelvarchar
        • amountdecimal
        First 5 rows of marketing_spend
        spend_datechannelamount
        2023-01-01paid492.99
        2023-01-01email37.38
        2023-01-01social200.53
        2023-01-02paid709.13
        2023-01-02email64.13

        The lesson

        What you are checking, and why

        An HR export looks tidy, but it is often edited by hand: people are re-entered after a role change, managers leave and their reports keep pointing at them. Before you total anything, check three things.

        • Is anyone listed twice? Ids are unique by design, so compare the row count with the count of distinct names as well.
        • Does every reporting line lead somewhere? A manager_id that matches no employee_id is an orphan. Only the CEO should have no manager at all.
        • Are the salaries plausible? MIN and MAX catch a monthly figure typed as yearly, or a missing zero.

        The reporting check needs a self-join: the table joined to itself under two aliases, one for the employee and one for the manager. A LEFT JOIN keeps every employee even when no manager row is found, and then that manager’s columns are NULL:

        FROM employees AS e
        LEFT JOIN employees AS m ON m.employee_id = e.manager_id

        An employee with a manager_id but a NULL in m.employee_id is an orphan. Here there are none.

        Headcount, payroll, mean and median

        Step 2 is a plain GROUP BY department with several aggregates side by side. The interesting pair is mean and median. The mean adds every salary and divides; the median is the middle salary when they are sorted. In a department of seven where one person is the head, the head’s salary moves the mean a lot and the median hardly at all:

        department headcount mean_salary median_salary
        Engineering 8 149,750 139,500
        Data & Analytics 7 108,000 92,000
        Sales 9 84,000 73,000
        Customer Support 9 59,333 50,000

        For “what does a typical person here earn”, report the median. Payroll is still the sum: that is what the budget pays.

        Span of control

        Span of control is the number of people who report directly to a manager. It is a common input to org design: too narrow and the company is top-heavy, too wide and managers cannot keep up. With the inner self-join, every row is one reporting line, so counting rows per manager gives the span. Group by the manager’s columns, from m.

        The spans here run from 2 (Anna Chen, Sales Manager) to 7. Support’s two managers have 5 and 3.

        Pitfalls

        • An inner join in the check hides the very rows you are looking for; use LEFT JOIN.
        • GROUP BY m.first_name || ' ' || m.last_name would merge two managers who share a name; grouping by id is safer in a big company.
        • Department means of 2 or 5 people can reveal individual salaries. Keep them out of reports that go beyond Finance and HR.

        Pick what may run on this site. You can change this any time from "Cookie settings" at the bottom of every page.