Module 10 Lab Companion

BAN 6003: Data Management and Analytics Integration

SQL Joins and ABT Preparation

Experimental pilot. Use the Ask about this lab panel (on the right, or the Ask a question button on small screens) to ask about the section you are reading, or open Common questions for saved answers. Answers explain the companion in plain language and may add general background. Each answer is labelled: instructor-approved answers were approved by your instructor; AI-generated answers are new and not yet reviewed. The notebook is still the authoritative source. Graded work (Section 9: Data Leakage; Section 12: Final Practice: Build a Preliminary ABT; Section 13: Offline Assignment: Route-Month Operations ABT) is not answered for you. Questions about grades, deadlines or extensions go to your instructor.

How to use this companion

Open the Module 10 notebook and this companion at the same time. Work through the companion from top to bottom and run the matching notebook cells as you go. The notebook is the authoritative source for the lab sequence, code and required work. This companion explains what each step means, what to notice in the output and why the step matters.

Read aloud: controls and directions

Before you start: choose a reading mode, speed, and voice below. Select Read Aloud from Beginning to hear the full companion, or use Read Aloud from This Section beside any major section to continue from that point. While audio is playing, a floating control bar remains visible at the bottom of the page. Use Pause to pause temporarily, Resume to continue, and Stop to end the current reading.

Study mode reads explanations, results, tables, and notes while skipping exact Python code. Full mode also reads Python code and verbalizes common programming symbols.

Adjust text-to-speech speed from one half to two times normal speed.

Ready to read.

The read-aloud feature uses your browser's speech engine. A screen reader can access the page independently of these controls.

What is in the notebook, and what this companion adds

Notebook contentCode, official prompts, required tasks and the lab sequence come from the notebook. Each code block shows its notebook cell number.
Companion-added instructor noteThese clearly labeled notes are explanations, cautions or study advice added by this companion, many of them from the in-person lab session. They are not extra required notebook steps unless the note says so.

This companion is designed for keyboard and screen-reader access, but it is not a separate version for students who need accessibility features. It is the same learning guide for everyone. You can read it visually, use a screen reader, or use the built-in read-aloud controls while you work in the notebook.

Use either course environment

↑ Back to Lab Contents

Module 10 Learning Goals

In Module 9 you queried one table at a time. In this lab you combine tables in SQL and shape the result into an Analytic Base Table (ABT): a table in which every row matches the unit of analysis of a business question. By the end of the lab you should be able to:

The main idea: decide what one row means first; then join, aggregate and check until every row means exactly that.

↑ Back to Lab Contents

Before You Start: Repository and Setup

Module 10 uses the same repository as Module 9, your copy of ban6003-week-09-10-sql-abt-template. Do not create a new one.

  1. Open your Week 9-10 repository in GitHub Codespaces (reopening an existing Codespace is fast; a new one takes about 4–5 minutes the first time). Optional for experienced students: local Visual Studio Code with the base conda environment.
  2. Open notebooks/module10_sql_joins_abt_guided_lab.ipynb. This is the graded lab. The second notebook, module10_part2_project_sql_options.ipynb, is not graded (see Section 11).
  3. Run the cells from the top. If you restart the kernel, run from the top again.

Section 0: Connect to Neon

The lab uses the same .env file and the same course database as Module 9. If Module 9 worked in this Codespace, nothing needs to change. In a new Codespace or a fresh clone, .env is empty again: the connection cell creates it and stops, you paste the string from the Canvas page “Module 9 Database Connection” after DATABASE_URL=, save, and run the cell again.

The install line (local only)

# Codespaces: these packages are installed from requirements.txt.
# Local only: if the imports below fail, uncomment and run this line:
# %pip install -q sqlalchemy psycopg2-binary "psycopg[binary]" python-dotenv pandas

Create or read .env and build the engine

import os

import pandas as pd
from dotenv import load_dotenv
from sqlalchemy import create_engine

from pathlib import Path

# Find the top folder of the repository (the folder that contains README.md).
repo_root = Path.cwd()
if not (repo_root / "README.md").exists() and (repo_root.parent / "README.md").exists():
    repo_root = repo_root.parent

# Create the .env file the first time. Git ignores this file, so it never goes to GitHub.
env_path = repo_root / ".env"
if not env_path.exists():
    env_path.write_text(
        '# Paste the connection string from the Canvas page "Module 9 Database Connection"\n'
        "# right after DATABASE_URL= (no spaces, no quotes). Then save this file.\n"
        "# Git ignores this file. Never commit it.\n"
        "DATABASE_URL=\n"
    )
    print("Created the file .env in the top folder of your repository.")

load_dotenv(env_path, override=True)
database_url = os.getenv("DATABASE_URL")

if not database_url:
    print("DATABASE_URL is empty. To fix it:")
    print("1. Open the file .env in the top folder of your repository (next to README.md).")
    print('2. Paste the connection string from the Canvas page "Module 9 Database Connection" right after DATABASE_URL=')
    print("3. Save .env and run this cell again.")
    raise RuntimeError("DATABASE_URL is not set. Follow the steps printed above.")

engine = create_engine(database_url)
print("Connection engine created. For safety, we do not print the connection string.")

The run_query() helper

def run_query(query, engine):
    """Run a SQL query when it has been filled in; keep blank practice cells runnable."""
    if not query or not query.strip():
        print("This practice cell is waiting for your SQL. Add your query inside the triple quotes, then run it again.")
        return pd.DataFrame()
    return pd.read_sql(query, engine)

↑ Back to Lab Contents

Section 1: Quick Database Check

List the course tables

query = '''
SELECT table_schema, table_name
FROM information_schema.tables
WHERE table_schema = 'public'
ORDER BY table_name;
'''

run_query(query, engine)

↑ Back to Lab Contents

Section 2: What Is an ABT?

An ABT is the table you hand to an analysis or a model. Section 2 of the notebook lists what a good ABT has: a clear unit of analysis, one row per entity at that unit, useful features, a target if you model, and documented assumptions about how it was built. The question behind all of them: what does one row mean?

↑ Back to Lab Contents

Sections 3–4: INNER JOIN with Table Aliases

A join combines rows from two tables on a key: a column, or set of columns, whose values match in both tables. INNER JOIN keeps only rows whose key appears in both tables; LEFT JOIN keeps every row of the left table.

4.1 Flights with airline names

query = '''
SELECT
    f.year,
    f.month,
    f.day,
    f.carrier,
    a.name AS airline_name,
    f.flight,
    f.origin,
    f.dest,
    f.dep_delay,
    f.arr_delay
FROM flights AS f
INNER JOIN airlines AS a
    ON f.carrier = a.carrier
LIMIT 10;
'''

run_query(query, engine)

↑ Back to Lab Contents

Your Turn 1: Flights with Destination Airport Names

Try first

Start from cell 14. Change the joined table, its alias and the key.

  1. Inner join flights with airports on flights.dest = airports.faa.
  2. Return the date columns, carrier, flight, origin, dest, the airport name as dest_airport_name and arr_delay; limit to 10 rows.
Show sample solution for Your Turn 1

Use this only after you have made your own attempt. Compare the structure and logic, not just the final line of code. This sample solution is written by the companion; it is not part of the notebook.

# Your Turn 1

query = '''
SELECT
    f.year,
    f.month,
    f.day,
    f.carrier,
    f.flight,
    f.origin,
    f.dest,
    ap.name AS dest_airport_name,
    f.arr_delay
FROM flights AS f
INNER JOIN airports AS ap
    ON f.dest = ap.faa
LIMIT 10;
'''

run_query(query, engine)

There is no ORDER BY, so the 10 rows may differ from your neighbor's, and even from run to run. Check the columns and the names instead of the exact rows.

↑ Back to Lab Contents

Section 5: LEFT JOIN and the Row-Count Check

5.1 Keep all flights

query = '''
SELECT
    f.year,
    f.month,
    f.day,
    f.carrier,
    f.flight,
    f.origin,
    f.dest,
    ap.name AS dest_airport_name,
    f.dep_delay,
    f.arr_delay
FROM flights AS f
LEFT JOIN airports AS ap
    ON f.dest = ap.faa
LIMIT 10;
'''

run_query(query, engine)

5.2 Count the rows before and after

query = '''
SELECT COUNT(*) AS flight_rows
FROM flights;
'''

run_query(query, engine)

query = '''
SELECT COUNT(*) AS joined_rows
FROM flights AS f
LEFT JOIN airports AS ap
    ON f.dest = ap.faa;
'''

run_query(query, engine)

query = '''
SELECT COUNT(*) AS inner_join_rows
FROM flights AS f
INNER JOIN airports AS ap
    ON f.dest = ap.faa;
'''

run_query(query, engine)

5.3 Which destinations have no match?

query = '''
SELECT
    f.dest,
    COUNT(*) AS flight_count
FROM flights AS f
LEFT JOIN airports AS ap
    ON f.dest = ap.faa
WHERE ap.faa IS NULL
GROUP BY f.dest
ORDER BY flight_count DESC;
'''

run_query(query, engine)

↑ Back to Lab Contents

Your Turn 2: Flights with Aircraft Details

Try first

Copy the left join pattern of cell 19.

  1. Left join flights with planes on tailnum. Return the date columns, carrier, flight, tailnum, manufacturer, model and seats; limit to 10 rows.
Show sample solution for Your Turn 2

Use this only after you have made your own attempt. Compare the structure and logic, not just the final line of code. This sample solution is written by the companion; it is not part of the notebook.

# Your Turn 2

query = '''
SELECT
    f.year,
    f.month,
    f.day,
    f.carrier,
    f.flight,
    f.tailnum,
    p.manufacturer,
    p.model,
    p.seats
FROM flights AS f
LEFT JOIN planes AS p
    ON f.tailnum = p.tailnum
LIMIT 10;
'''

run_query(query, engine)

Again no ORDER BY, so your 10 rows may differ. Check the columns and the join, not the exact rows.

↑ Back to Lab Contents

Section 6: CTEs with WITH

A CTE (Common Table Expression) gives a name to one step inside a query. The notebook calls it a “named temporary result”; it is not a table saved in the database, and it exists only while that one query runs.

6.1 Select columns first, then join

query = '''
WITH flight_base AS (
    SELECT
        year,
        month,
        day,
        carrier,
        flight,
        origin,
        dest,
        dep_delay,
        arr_delay,
        distance,
        air_time
    FROM flights
)
SELECT
    fb.*,
    a.name AS airline_name
FROM flight_base AS fb
LEFT JOIN airlines AS a
    ON fb.carrier = a.carrier
LIMIT 10;
'''

run_query(query, engine)

↑ Back to Lab Contents

Your Turn 3: A CTE for JFK Flights

Try first

Start from cell 32. Add a filter inside the CTE and rename it.

  1. Create a CTE called jfk_flights that keeps flights with origin = 'JFK', join it to airlines for the airline name, and return 10 rows.
Show sample solution for Your Turn 3

Use this only after you have made your own attempt. Compare the structure and logic, not just the final line of code. This sample solution is written by the companion; it is not part of the notebook.

# Your Turn 3

query = '''
WITH jfk_flights AS (
    SELECT
        year,
        month,
        day,
        carrier,
        flight,
        origin,
        dest,
        dep_delay,
        arr_delay
    FROM flights
    WHERE origin = 'JFK'
)
SELECT
    jf.*,
    a.name AS airline_name
FROM jfk_flights AS jf
LEFT JOIN airlines AS a
    ON jf.carrier = a.carrier
LIMIT 10;
'''

run_query(query, engine)

No ORDER BY, so the rows may differ. Every row should show origin = JFK and an airline name.

↑ Back to Lab Contents

Section 7: Aggregate Before Joining

7.1 One row per carrier-month

query = '''
SELECT
    carrier,
    month,
    COUNT(*) AS flight_count,
    ROUND(AVG(dep_delay)::numeric, 2)::float AS avg_dep_delay,
    ROUND(AVG(arr_delay)::numeric, 2)::float AS avg_arr_delay,
    ROUND(AVG(distance)::numeric, 2)::float AS avg_distance
FROM flights
GROUP BY carrier, month
ORDER BY carrier, month;
'''

carrier_month_summary = run_query(query, engine)
carrier_month_summary.head()

7.2 Then add the airline name

query = '''
WITH carrier_month AS (
    SELECT
        carrier,
        month,
        COUNT(*) AS flight_count,
        ROUND(AVG(dep_delay)::numeric, 2)::float AS avg_dep_delay,
        ROUND(AVG(arr_delay)::numeric, 2)::float AS avg_arr_delay,
        ROUND(AVG(distance)::numeric, 2)::float AS avg_distance
    FROM flights
    GROUP BY carrier, month
)
SELECT
    cm.*,
    a.name AS airline_name
FROM carrier_month AS cm
LEFT JOIN airlines AS a
    ON cm.carrier = a.carrier
ORDER BY cm.carrier, cm.month;
'''

carrier_month_abt = run_query(query, engine)
carrier_month_abt.head()

7.3 Light ABT check

carrier_month_abt[["carrier", "month"]].duplicated().sum()

carrier_month_abt.isna().sum()

↑ Back to Lab Contents

Your Turn 4: A Route Summary

Try first

Copy the structure of cell 40: aggregate in the CTE, then join.

  1. One row per origin and dest with the number of flights and the rounded average departure delay, arrival delay and distance.
  2. Left join airports on dest = faa for dest_airport_name. Then fill in the check cell below it.
Show sample solution for Your Turn 4

Use this only after you have made your own attempt. Compare the structure and logic, not just the final line of code. This sample solution is written by the companion; it is not part of the notebook.

# Your Turn 4

query = '''
WITH route_summary AS (
    SELECT
        origin,
        dest,
        COUNT(*) AS flight_count,
        ROUND(AVG(dep_delay)::numeric, 2)::float AS avg_dep_delay,
        ROUND(AVG(arr_delay)::numeric, 2)::float AS avg_arr_delay,
        ROUND(AVG(distance)::numeric, 2)::float AS avg_distance
    FROM flights
    GROUP BY origin, dest
)
SELECT
    rs.*,
    ap.name AS dest_airport_name
FROM route_summary AS rs
LEFT JOIN airports AS ap
    ON rs.dest = ap.faa
ORDER BY rs.origin, rs.dest;
'''

route_abt = run_query(query, engine)
route_abt.head()

What it prints:

  origin dest  flight_count  avg_dep_delay  avg_arr_delay  avg_distance  \
0    EWR  ALB           439          23.62          14.40         143.0
1    EWR  ANC             8          12.88          -2.50        3370.0
2    EWR  ATL          5022          15.50          13.23         746.0
3    EWR  AUS           968          11.51          -0.47        1504.0
4    EWR  AVL           265           8.62           8.80         583.0

                 dest_airport_name
0                      Albany Intl
1       Ted Stevens Anchorage Intl
2  Hartsfield Jackson Atlanta Intl
3            Austin Bergstrom Intl
4       Asheville Regional Airport  
Show sample solution for the Your Turn 4 check

Use this only after you have made your own attempt. Compare the structure and logic, not just the final line of code. This sample solution is written by the companion; it is not part of the notebook.

# Your Turn 4 check
# Check whether each origin-dest pair appears once.
print("Rows:", len(route_abt))
print("Duplicate origin-dest pairs:", route_abt[["origin", "dest"]].duplicated().sum())
print("Routes without a destination name:", route_abt["dest_airport_name"].isna().sum())

What it prints:

Rows: 224
Duplicate origin-dest pairs: 0
Routes without a destination name: 7

↑ Back to Lab Contents

Section 8: A Preliminary Flight-Level ABT

8.1 One row per flight, with three lookups

query = '''
WITH flight_base AS (
    SELECT
        year,
        month,
        day,
        carrier,
        flight,
        origin,
        dest,
        dep_delay,
        arr_delay,
        distance,
        air_time
    FROM flights
)
SELECT
    fb.*,
    a.name AS airline_name,
    dest_ap.name AS dest_airport_name,
    origin_ap.name AS origin_airport_name
FROM flight_base AS fb
LEFT JOIN airlines AS a
    ON fb.carrier = a.carrier
LEFT JOIN airports AS dest_ap
    ON fb.dest = dest_ap.faa
LEFT JOIN airports AS origin_ap
    ON fb.origin = origin_ap.faa
LIMIT 1000;
'''

flight_level_abt = run_query(query, engine)
flight_level_abt.head()

flight_level_abt.shape

flight_level_abt.isna().sum()

↑ Back to Lab Contents

Section 9: Data Leakage

Leakage means an ABT includes information that would not be known when the prediction is made. A leaky model looks very accurate in testing and fails in real use.

Quick reflection (graded written answer)

This is one of the three graded written answers, so the companion does not answer it. Write which columns should not be predictors if the target is arr_delay, and why. The note above gives you the test to apply; say for which prediction time your answer holds.

↑ Back to Lab Contents

Section 10: Optional Demo: ROW_NUMBER()

Optional and not graded, but useful if your project uses SQL heavily. ROW_NUMBER() numbers the rows inside each group, a ranking within a group.

10.1 Each carrier's most delayed departure

query = '''
WITH ranked_flights AS (
    SELECT
        carrier,
        year,
        month,
        day,
        flight,
        origin,
        dest,
        dep_delay,
        ROW_NUMBER() OVER (
            PARTITION BY carrier
            ORDER BY dep_delay DESC NULLS LAST
        ) AS rn
    FROM flights
)
SELECT *
FROM ranked_flights
WHERE rn = 1
ORDER BY dep_delay DESC;
'''

run_query(query, engine)

Try first

Optional extension from the lab session: rank months instead of flights. You need two steps, so use two CTEs.

  1. For each carrier, find the month with the highest average departure delay. First aggregate to carrier-month, then number the months inside each carrier.
Show sample solution for the worst-month extension

Use this only after you have made your own attempt. Compare the structure and logic, not just the final line of code. This sample solution is written by the companion; it is not part of the notebook.

query = '''
WITH carrier_month AS (
    SELECT
        carrier,
        month,
        COUNT(*) AS flight_count,
        ROUND(AVG(dep_delay)::numeric, 2)::float AS avg_dep_delay
    FROM flights
    GROUP BY carrier, month
),
ranked_months AS (
    SELECT
        *,
        ROW_NUMBER() OVER (
            PARTITION BY carrier
            ORDER BY avg_dep_delay DESC NULLS LAST
        ) AS rn
    FROM carrier_month
)
SELECT carrier, month, flight_count, avg_dep_delay
FROM ranked_months
WHERE rn = 1
ORDER BY avg_dep_delay DESC;
'''

run_query(query, engine)

What it prints:

   carrier  month  flight_count  avg_dep_delay
0       OO      1             1          67.00
1       HA      1            31          54.39
2       YV      6            49          42.79
3       FL      7           263          41.16
4       F9      5            58          35.95
5       VX      7           489          35.26
6       9E      7          1494          31.40
7       WN      6          1028          30.51
8       EV     12          4307          27.89
9       B6      7          4984          24.90
10      MQ      6          2178          20.84
11      DL      7          4251          20.58
12      UA      6          4975          20.27
13      AS     12            54          18.02
14      AA      6          2757          14.63
15      US      6          1736          10.40

16 rows, one per carrier. Look at the top row: OO's “worst month” is January with a single flight. As in Module 9, add HAVING COUNT(*) >= 100 inside the first CTE if small months should not count.

↑ Back to Lab Contents

Section 11: SQL on Your Own Project Data (Part 2, Not Graded)

Milestone 2 requires at least one SQL query on your own project data, inside your project notebook. The notebook module10_part2_project_sql_options.ipynb shows both ways to do it. It is not graded and not part of the Module 10 submission; you use it for the project. Pick one path:

↑ Back to Lab Contents

Section 12: Final Practice: Build a Preliminary ABT

Complete independently

The final practice is graded, so the companion does not solve it.

Before coding: choose the unit (carrier-month, route, or flight with a filter) and write down, in words, what one row means and which columns form its key. Then follow the pattern of Section 7: build the rows in a CTE, join one lookup for a descriptive field, and select at least three features.

What to check: fill in the three check cells (shape, missing values, duplicate keys on your key columns). The duplicate count must be 0. Explain every missing value you find. For a flight-level ABT, the WHERE filter (one origin airport and one month) is required: the export cell refuses more than 50,000 rows.

output_dir = repo_root / "data" / "processed"
output_dir.mkdir(parents=True, exist_ok=True)

if my_abt.empty:
    print("Complete the Final Practice query before exporting.")
elif len(my_abt) > 50000:
    print(f"Your ABT has {len(my_abt):,} rows, so it was not saved.")
    print("For a flight-level ABT, add a WHERE filter (for example, one origin airport and one month) and run the query again.")
else:
    final_output_path = output_dir / "preliminary_abt_module10.csv"
    my_abt.to_csv(final_output_path, index=False)
    print(f"Saved {len(my_abt)} rows to: {final_output_path}")

Final reflection

Write 5–7 sentences: the unit of analysis, the keys, the tables you joined, whether and why you aggregated before joining, the checks you ran, and what you would still document before using the ABT.

↑ Back to Lab Contents

Section 13: Offline Assignment: Route-Month Operations ABT

Complete independently

This assignment is intentionally not solved in the companion. Follow the six steps and the suggested structure in the notebook cell.

Before coding: one row is one route in one month, so the key has three columns. Decide which steps belong inside the CTE (grouping, the 100-flight rule) and which come after it (the two airport joins, the sort). airports plays two roles here, as in Section 8, so it needs two aliases.

What to check: the validation cell prints the shape, the duplicate route-month keys (must be 0) and the missing values. If some airport names are missing, check which airports they are before you call them an error (Section 5). The cell saves data/processed/route_month_abt_module10.csv.

if route_month_abt.empty:
    print("Complete the offline ABT query before validating and exporting.")
else:
    print("Shape:", route_month_abt.shape)
    print(
        "Duplicate route-month keys:",
        route_month_abt[["origin", "dest", "month"]].duplicated().sum()
    )
    print("Missing values by column:")
    display(route_month_abt.isna().sum())

    offline_output_path = output_dir / "route_month_abt_module10.csv"
    route_month_abt.to_csv(offline_output_path, index=False)
    print(f"Saved {len(route_month_abt)} rows to: {offline_output_path}")

ABT recommendation

Write 5–6 sentences: the unit and key, why aggregation comes before the airport joins, what the 100-flight threshold does, what your validation numbers show, and one limitation or leakage concern to document before modeling.

↑ Back to Lab Contents

Section 14: Optional Challenge (Not Graded)

For students who already know SQL. Optional and not graded: finish the final practice and the offline assignment first. Each challenge needs one SQL idea beyond the module; the hints in the notebook name it.

Try first

Use the hints in the notebook. An empty challenge cell still runs without an error.

  1. Challenge 1: weather with a two-column key, and delays by visibility group (CASE WHEN).
  2. Challenge 2: which carriers lose the most flights without an aircraft record (COUNT(*) FILTER (WHERE ...)).
  3. Challenge 3: a carrier-month ABT with next month's delay as the target (LEAD).
Show sample solution for Challenge 1

Use this only after you have made your own attempt. Compare the structure and logic, not just the final line of code. This sample solution is written by the companion; it is not part of the notebook.

# Challenge 1 (optional)
query = '''
SELECT
    COUNT(*) AS weather_rows,
    COUNT(DISTINCT (origin, time_hour)) AS distinct_origin_hours
FROM weather;
'''

display(run_query(query, engine))

# Step 2-4: join on both key columns and group by visibility
query = '''
WITH flight_weather AS (
    SELECT
        f.dep_delay,
        CASE
            WHEN w.visib IS NULL THEN 'no weather record'
            WHEN w.visib < 1 THEN 'under 1 mile'
            WHEN w.visib < 5 THEN '1 to 5 miles'
            ELSE '5 miles or more'
        END AS visibility_group
    FROM flights AS f
    LEFT JOIN weather AS w
        ON f.origin = w.origin
       AND f.time_hour = w.time_hour
)
SELECT
    visibility_group,
    COUNT(*) AS flight_count,
    ROUND(AVG(dep_delay)::numeric, 2)::float AS avg_dep_delay
FROM flight_weather
GROUP BY visibility_group
ORDER BY avg_dep_delay DESC;
'''

run_query(query, engine)

What it prints:

   weather_rows  distinct_origin_hours
0         26115                  26115

    visibility_group  flight_count  avg_dep_delay
0       under 1 mile          3975          29.72
1       1 to 5 miles         15040          23.47
2  no weather record          1556          13.43
3    5 miles or more        316205          11.95

The key is unique (26,115 = 26,115), so the join cannot add rows; the four groups add up to 336,776. Low visibility goes with longer delays, an association and not proof of a cause.

Show sample solution for Challenge 2

Use this only after you have made your own attempt. Compare the structure and logic, not just the final line of code. This sample solution is written by the companion; it is not part of the notebook.

# Challenge 2 (optional)

query = '''
SELECT
    f.carrier,
    COUNT(*) AS flights_without_plane_record,
    COUNT(*) FILTER (WHERE f.tailnum IS NULL) AS missing_tailnum
FROM flights AS f
LEFT JOIN planes AS p
    ON f.tailnum = p.tailnum
WHERE p.tailnum IS NULL
GROUP BY f.carrier
ORDER BY flights_without_plane_record DESC;
'''

run_query(query, engine)

What it prints:

  carrier  flights_without_plane_record  missing_tailnum
0      MQ                         25397                2
1      AA                         22558               84
2      UA                          1693              686
3      9E                          1044             1044
4      B6                           830                0
5      US                           699              663
6      FL                           187                0
7      DL                           110                0
8      F9                            50                3
9      WN                            38               30

The column adds up to 52,606. MQ and AA lose the most flights, and almost all of them do have a tailnum: the planes table simply lacks most of their aircraft. An inner join with planes would drop about 48,000 of their flights and make a carrier comparison unfair.

Show sample solution for Challenge 3

Use this only after you have made your own attempt. Compare the structure and logic, not just the final line of code. This sample solution is written by the companion; it is not part of the notebook.

# Challenge 3 (optional)

query = '''
WITH carrier_month AS (
    SELECT
        carrier,
        month,
        COUNT(*) AS flight_count,
        ROUND(AVG(dep_delay)::numeric, 2)::float AS avg_dep_delay,
        ROUND(AVG(arr_delay)::numeric, 2)::float AS avg_arr_delay
    FROM flights
    GROUP BY carrier, month
)
SELECT
    carrier,
    month,
    flight_count,
    avg_dep_delay,
    avg_arr_delay,
    LEAD(avg_arr_delay) OVER (PARTITION BY carrier ORDER BY month) AS next_month_avg_arr_delay
FROM carrier_month
ORDER BY carrier, month;
'''

next_month_abt = run_query(query, engine)
next_month_abt.head(12)

What it prints:

   carrier  month  flight_count  avg_dep_delay  avg_arr_delay  \
0       9E      1          1573          16.88          10.21   
1       9E      2          1459          16.49           8.28   
2       9E      3          1627          13.41           2.03   
3       9E      4          1511          13.57           5.47   
4       9E      5          1462          22.67          10.42   
5       9E      6          1437          28.95          22.51   
6       9E      7          1494          31.40          23.76   
7       9E      8          1456          17.30           5.31   
8       9E      9          1540           7.75          -7.14   
9       9E     10          1673           9.33          -1.35   
10      9E     11          1595           7.56          -0.03   
11      9E     12          1633          19.78          14.05   

    next_month_avg_arr_delay  
0                       8.28  
1                       2.03  
2                       5.47  
3                      10.42  
4                      22.51  
5                      23.76  
6                       5.31  
7                      -7.14  
8                      -1.35  
9                      -0.03  
10                     14.05  
11                       NaN  

185 rows; 16 have no target (each carrier's last month). For OO the next row is not always the next calendar month (January is followed by June): check with LEAD(month) ... - month and set the target to NULL when the gap is not 1. This month's average is known at the end of the month, before next month starts, so it is a fair feature for next month's target.

↑ Back to Lab Contents

Common Problems

If a cell fails, read the last line of the error message first. Then find it below.

Still stuck? Post the error message and the cell number in the course Q&A discussion. Never post a connection string, and do not post your full answers.

↑ Back to Lab Contents

Save and Submit

  1. Complete Your Turn 1–4, the final practice and the offline assignment. The optional demo and challenges are not required, and the Part 2 notebook is not part of this submission.
  2. Complete all three written answers: the data leakage quick reflection (Section 9), the Final Reflection (Section 12) and the ABT Recommendation (Section 13).
  3. Restart the kernel and run all cells from top to bottom. Confirm there are no errors.
  4. Confirm both files exist in data/processed: preliminary_abt_module10.csv and route_month_abt_module10.csv.
  5. Confirm that .env is not tracked by Git.
  6. Save, then commit and push: git add ., git commit -m "Complete Module 10 SQL joins and ABT lab", git push. Check on GitHub that the commit and both CSV files are there, and submit the repository link in Canvas.

↑ Back to Lab Contents

Lab Wrap-Up

You built ABTs in SQL with one repeatable pattern:

Next, Milestone 2 asks for at least one SQL query on your own project data (Part 2 notebook). Module 11 then turns your ABT into a documented hand-off: final checks, a data dictionary and a validation checklist.