5.4 SQL Join Semantics and Integrated Analytics

Key Takeaways

  • An inner join can multiply a left row when multiple right rows match.

  • Semi and anti joins project left columns; they preserve existing left duplicates rather than creating a distinct set.

  • A cross join has M × N result rows, so estimate its size before execution.

  • Use explicit join keys and aliases to make an integrated analytical query unambiguous.

Last updated: October 2026

5. Complex Joins in Spark SQL: Semantics and Execution

Joining distributed datasets is central to analytical query processing. Understanding how each join variety manages unmatched keys, schema projection, and row multiplication is essential for query optimization.

Standard Joins: Inner and Outer

  • INNER JOIN: Emits rows only when join keys match in both tables. If multiple matching records exist on the right side, the left row is duplicated. Columns from both tables are present in the output.
  • LEFT OUTER JOIN: Emits all rows from the left table. Matching rows from the right table are joined; unmatched left rows have NULL populated for all right-table columns.
  • RIGHT OUTER JOIN: Emits all rows from the right table, populating NULL for unmatched left-table columns.
  • FULL OUTER JOIN: Emits all rows from both tables, filling missing attributes on either side with NULL.
  • CROSS JOIN: Produces the Cartesian product (M×NM \times N rows). Because cross joins can accidentally produce trillions of rows and crash executors with Out-Of-Memory errors, use explicit crossJoin() to make intent clear. spark.sql.crossJoin.enabled controls implicit Cartesian joins and defaults to true in upstream Spark 3.5; rejection is not a universal default.

Specialized Filtering Joins: Semi and Anti Joins

Spark SQL provides two highly optimized filtering joins that correspond directly to subquery predicates:

1. LEFT SEMI JOIN (Equivalent to WHERE EXISTS or IN)

A Left Semi Join filters the left table to include only rows that have at least one matching key in the right table:

  • Output Schema: The resulting DataFrame contains ONLY columns from the left table. Columns from the right table cannot be referenced or projected.
  • No Row Duplication: Even if a left-table key matches 10,000 rows in the right table, the left row is emitted exactly once.
-- Emits departments that have at least one active employee
SELECT d.* 
FROM corporate.departments d
LEFT SEMI JOIN corporate.employees e ON d.dept_id = e.dept_id;

2. LEFT ANTI JOIN (Equivalent to WHERE NOT EXISTS)

A Left Anti Join filters the left table to include only rows that have no matching key in the right table:

  • Output Schema: The resulting DataFrame contains ONLY columns from the left table.
  • Exclusion Filter: Emits left rows where the right-side lookup yields zero matches.
-- Emits orphaned departments that contain zero employees
SELECT d.* 
FROM corporate.departments d
LEFT ANTI JOIN corporate.employees e ON d.dept_id = e.dept_id;

Comprehensive Join Comparison Table

Join TypeSpark SQL SyntaxPySpark SyntaxOutput ColumnsDuplicate Right Match Behavior
Innert1 JOIN t2 ON ...t1.join(t2, on, "inner")Both tablesLeft row is duplicated for each right match
Left Outert1 LEFT JOIN t2 ON ...t1.join(t2, on, "left")Both tablesDuplicates on multiple matches; fills NULL if no match
Right Outert1 RIGHT JOIN t2 ON ...t1.join(t2, on, "right")Both tablesDuplicates on multiple matches; fills NULL if no match
Full Outert1 FULL JOIN t2 ON ...t1.join(t2, on, "outer")Both tablesRetains all rows from both sides; fills NULL
Left Semit1 LEFT SEMI JOIN t2 ON ...t1.join(t2, on, "left_semi")Left table ONLYNever duplicates; emits left row once if match exists
Left Antit1 LEFT ANTI JOIN t2 ON ...t1.join(t2, on, "left_anti")Left table ONLYNever duplicates; emits left row once if NO match exists
Crosst1 CROSS JOIN t2t1.crossJoin(t2)Both tablesFull Cartesian product (M×NM \times N rows)

6. Real-World Scenario: Customer Cohort and Retention Analytics Walkthrough

The following production script analyzes subscription retention and period-over-period revenue trends using chained window specifications, ranking, and semi/anti join filtering:

WITH 
    -- Step 1: Active subscribers filtered via Left Semi Join with active payments
    retained_subscribers AS (
        SELECT s.subscription_id, s.customer_id, s.plan_tier, s.monthly_fee, s.signup_date
        FROM billing.subscriptions s
        LEFT SEMI JOIN billing.payment_records p 
            ON s.subscription_id = p.subscription_id 
            AND p.payment_status = 'SETTLED'
            AND p.payment_date >= add_months(current_date(), -1)
    ),
    -- Step 2: Calculate customer lifetime metrics and moving averages
    subscription_analytics AS (
        SELECT 
            customer_id,
            plan_tier,
            monthly_fee,
            signup_date,
            -- Unique sequential customer signup order within tier
            ROW_NUMBER() OVER (PARTITION BY plan_tier ORDER BY signup_date ASC) AS signup_sequence,
            -- Rank by fee within tier
            DENSE_RANK() OVER (PARTITION BY plan_tier ORDER BY monthly_fee DESC) AS fee_rank,
            -- Moving 3-subscriber average fee
            AVG(monthly_fee) OVER (
                PARTITION BY plan_tier 
                ORDER BY signup_date 
                ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING
            ) AS moving_avg_fee,
            -- Previous subscriber fee to compute step delta
            LAG(monthly_fee, 1, 0.0) OVER (
                PARTITION BY plan_tier 
                ORDER BY signup_date, subscription_id
            ) AS prev_subscriber_fee
        FROM retained_subscribers
    )
-- Final Step: Isolate top-tier customers while excluding flagged fraud accounts
SELECT 
    sa.customer_id,
    sa.plan_tier,
    sa.monthly_fee,
    sa.signup_sequence,
    sa.fee_rank,
    round(sa.moving_avg_fee, 2) AS moving_avg_fee,
    round(sa.monthly_fee - sa.prev_subscriber_fee, 2) AS fee_delta_from_prev
FROM subscription_analytics sa
LEFT ANTI JOIN security.flagged_fraud_accounts f 
    ON sa.customer_id = f.customer_id
WHERE sa.fee_rank <= 5
ORDER BY sa.plan_tier, sa.fee_rank;
Loading diagram...
Inner vs Left Semi vs Left Anti Join Mechanics
Test Your Knowledge

A developer joins a departments table (containing 5 rows) with an employees table (containing 100 rows, where multiple employees belong to each department) using a LEFT SEMI JOIN:

departments.join(employees, departments['dept_id'] == employees['dept_id'], 'left_semi')

Which statement correctly describes the output DataFrame?

A

The output DataFrame contains all columns from both tables, with department rows duplicated for each matching employee.

B

The output DataFrame contains only rows from the departments table that have no matching employees, retaining only departments columns.

C

The output DataFrame contains only columns from the departments table, retaining each department that has at least one employee exactly once without row duplication.

D

The output DataFrame contains all 5 department rows, with NULL values populated for departments that have no employees.

Sections you finish are checked off in the contents.