Joins, Subqueries, LATERAL, and Set Operators

PostgreSQL row-combination work is about preserving, multiplying, filtering, or reshaping rowsets deliberately. The language surface is similar to other SQL engines for core joins and subqueries, but two boundaries matter immediately in PostgreSQL: SQL Server APPLY patterns map to LATERAL, and SQL Server PIVOT or UNPIVOT work is usually written as conditional aggregates or VALUES-driven row expansion instead of dedicated keywords.

Scope

This note mirrors the SQL Server row-combination track with PostgreSQL equivalents. It covers join retention rules, correlated subqueries, semi-joins and anti-joins, LATERAL as the PostgreSQL analogue to APPLY, set operators, and row-to-column or column-to-row reshaping patterns.

  • Join families show how INNER, LEFT, RIGHT, and FULL OUTER joins control row preservation and why right-side predicates belong in the ON clause when the left side must survive.
  • Cross joins and self joins show deliberate row multiplication versus row-to-row comparison against the same base table.
  • Semi-joins, anti-joins, and subqueries show presence checks with EXISTS, absence checks with NOT EXISTS, and the row-cardinality boundary of scalar subqueries.
  • LATERAL covers PostgreSQL’s row-wise derived-rowset pattern for per-parent top-N and optional derived lookups.
  • Set operators and transpose patterns cover UNION, UNION ALL, EXCEPT, INTERSECT, conditional aggregates, and CROSS JOIN LATERAL (VALUES ...).

Join Families

Join choice is fundamentally about match retention. INNER JOIN keeps only matched rows, LEFT JOIN preserves the left side, RIGHT JOIN preserves the right side, and FULL OUTER JOIN preserves both. PostgreSQL supports the full family, but production code is usually clearest when the driving rowset is written on the left and preserved with LEFT JOIN.

Matching versus preserving rows

The critical question is not “can these tables be joined?” but “which side must survive if no match exists?” The live stoxx data makes that distinction easy to demonstrate because the index metadata table is small, stable, and easy to compare to the performance facts.

Join index metadata to the latest performance facts

Use this pattern when a query needs attributes from one table and measurements from another and unmatched rows have no business meaning. It is typically triggered by reporting, enrichment, or validation queries against a fact table that is expected to have a corresponding dimension entry. The query runs read-only against bronze.dim_index and gold.index_performance. Its purpose is to show the baseline matched-row pattern before outer-join preservation rules are introduced.

FieldSourceTypeMeaning
index_keybronze.dim_index.index_keyvarcharCanonical index identifier used across the warehouse.
display_namebronze.dim_index.display_namevarcharHuman-readable index label.
perf_dategold.index_performance.perf_datedatePerformance date of the fact row.
daily_returngold.index_performance.daily_returndouble precisionDaily return captured for the index on that date.

This query inner-joins the current dimension surface to the latest index-performance rows.

SELECT
    d.index_key,
    d.display_name,
    p.perf_date,
    ROUND(p.daily_return::numeric, 6) AS daily_return
FROM bronze.dim_index AS d
JOIN gold.index_performance AS p
  ON p._index = d.index_key
WHERE p.perf_date = (SELECT MAX(perf_date) FROM gold.index_performance)
ORDER BY d.index_key;
index_keydisplay_nameperf_datedaily_return
euro_stoxx_50Euro Stoxx 502026-04-07-0.007931
oil_20Oil & Gas 202026-04-070.006887
stoxx_asia_50STOXX Asia/Pacific 502026-04-070.005919
stoxx_usa_50STOXX USA 502026-04-070.000962

All four current index dimension rows matched a latest performance row, so the join returned one row per index. That is the simplest join case: the query is not trying to preserve missing dimension members or surface data gaps. It is only returning the intersection of both rowsets.

Keep the driving rowset outer by pushing right-side filters into ON

Use this pattern when the left-side rowset is the thing that must survive, even if the right side is absent. It is typically triggered by reconciliation work, exception reporting, or “show me all requested keys, with data when available” queries. The query is read-only. Its purpose is to make the most common outer-join bug visible: a right-side filter in WHERE silently collapses a LEFT JOIN into matched rows only.

FieldSourceTypeMeaning
index_keyinline VALUES rowsettextCandidate key that should remain visible even if no fact row exists.
perf_dategold.index_performance.perf_datedateLatest fact date when a matching row is present.

Right-side WHERE filters erase outer rows

A LEFT JOIN only preserves the left side until the WHERE clause runs. If WHERE references a right-side column, every unmatched left row carries NULL into that predicate and is filtered away.

This query is logically broken because the WHERE predicate removes the unmatched crypto_10 row.

WITH candidate(index_key) AS (
    VALUES ('euro_stoxx_50'), ('oil_20'), ('crypto_10')
)
SELECT
    c.index_key,
    p.perf_date
FROM candidate AS c
LEFT JOIN gold.index_performance AS p
  ON p._index = c.index_key
WHERE p.perf_date = (SELECT MAX(perf_date) FROM gold.index_performance)
ORDER BY c.index_key;
index_keyperf_date
euro_stoxx_502026-04-07
oil_202026-04-07

The unmatched key disappeared entirely, so the result no longer behaves like an outer join. The join itself preserved crypto_10; the later WHERE clause removed it.

Put nullable-side predicates in ON

Keep predicates that define match eligibility on the nullable side inside the ON clause. The join will then decide whether the right side matches, while the left side still survives when it does not.

This corrected query preserves the candidate key list and only restricts which right-side rows may match.

WITH candidate(index_key) AS (
    VALUES ('euro_stoxx_50'), ('oil_20'), ('crypto_10')
)
SELECT
    c.index_key,
    p.perf_date
FROM candidate AS c
LEFT JOIN gold.index_performance AS p
  ON p._index = c.index_key
 AND p.perf_date = (SELECT MAX(perf_date) FROM gold.index_performance)
ORDER BY c.index_key;
index_keyperf_date
crypto_10
euro_stoxx_502026-04-07
oil_202026-04-07

This result preserves the requested keys and expresses the real business meaning: two keys have current facts and one does not. That is what LEFT JOIN is for.

Reconcile expected versus observed keys with FULL OUTER JOIN

Use FULL OUTER JOIN when both sides matter and the query must surface “present in both”, “present only on the left”, and “present only on the right” in one pass. It is typically triggered by reconciliation after a load, feed-coverage checks, or migration validation. The query is read-only. Its purpose is to show the cleanest PostgreSQL pattern for four-state reconciliation.

FieldSourceTypeMeaning
index_keyCOALESCE(e.index_key, o.index_key)textUnified business key from either side of the comparison.
match_statecomputed CASE expressiontextReconciliation state: matched, expected only, or observed only.

This query compares an expected key list to the latest observed fact keys and classifies every key state.

WITH expected(index_key) AS (
    VALUES ('euro_stoxx_50'), ('oil_20'), ('frontier_15')
),
observed(index_key) AS (
    SELECT _index
    FROM gold.index_performance
    WHERE perf_date = (SELECT MAX(perf_date) FROM gold.index_performance)
)
SELECT
    COALESCE(e.index_key, o.index_key) AS index_key,
    CASE
        WHEN e.index_key IS NOT NULL AND o.index_key IS NOT NULL THEN 'matched'
        WHEN e.index_key IS NOT NULL THEN 'expected_only'
        ELSE 'observed_only'
    END AS match_state
FROM expected AS e
FULL OUTER JOIN observed AS o
  ON o.index_key = e.index_key
ORDER BY index_key;
index_keymatch_state
euro_stoxx_50matched
frontier_15expected_only
oil_20matched
stoxx_asia_50observed_only
stoxx_usa_50observed_only

The result exposes all reconciliation states in a single query. frontier_15 is missing from the observed set, while stoxx_asia_50 and stoxx_usa_50 arrived even though they were not part of the expected list. This is exactly the kind of difference map that is awkward with separate LEFT JOIN queries but natural with FULL OUTER JOIN.

Cross Joins and Self Joins

Not every multi-table query is a lookup. Some queries deliberately multiply rows to create combinations, while others compare one row to another row from the same base table. PostgreSQL handles both patterns with standard SQL syntax; the operational difference is whether the multiplication is intentional.

Deliberate row multiplication versus row-to-row comparison

CROSS JOIN creates a Cartesian product. A self join reuses the same base table twice under different aliases to create two logical roles. Both are valid tools, but both need clear intent because they can expand row counts quickly.

Generate every combination of recent dates and selected indexes

Use CROSS JOIN when every row on one side should pair with every row on the other. It is typically triggered by grid generation, calendar expansion, test harnesses, or backfill scaffolding. The query is read-only. Its purpose is to show deliberate Cartesian multiplication on live warehouse metadata rather than an accidental missing predicate.

FieldSourceTypeMeaning
datebronze.trading_calendar.datedateTrading date selected from the recent calendar window.
index_keybronze.dim_index.index_keyvarcharSelected index identifier.

This query multiplies three recent NMS trading dates by two chosen indexes to form a six-row grid.

WITH recent_days AS (
    SELECT date
    FROM bronze.trading_calendar
    WHERE exchange_code = 'NMS'
      AND is_trading_day
    ORDER BY date DESC
    LIMIT 3
),
chosen_indexes AS (
    SELECT index_key
    FROM bronze.dim_index
    WHERE index_key IN ('euro_stoxx_50', 'stoxx_usa_50')
)
SELECT
    d.date,
    i.index_key
FROM recent_days AS d
CROSS JOIN chosen_indexes AS i
ORDER BY d.date DESC, i.index_key;
dateindex_key
2027-03-12euro_stoxx_50
2027-03-12stoxx_usa_50
2027-03-11euro_stoxx_50
2027-03-11stoxx_usa_50
2027-03-10euro_stoxx_50
2027-03-10stoxx_usa_50

The row-count rule is explicit: 3 recent days x 2 index keys = 6 output rows. That formula is what makes a CROSS JOIN safe. If the multiplication is not intentional, it is usually a bug.

Compare each trading day to the same symbol seven days earlier

Use a self join when one table must play two logical roles in the same query. It is typically triggered by point-in-time comparison, hierarchy traversal, or prior-period analysis. The query is read-only against silver.stoxxusa50_ohlcv. Its purpose is to show the classic row-to-row comparison pattern on a real market-data series.

FieldSourceTypeMeaning
symbolcur.symbolvarcharEquity ticker carried from the current-row side.
datecur.datedateCurrent trading date.
current_closecur.closenumeric projectionCurrent close price rounded for display.
prev_closeprev.closenumeric projectionClose price exactly seven calendar days earlier.
seven_day_changecur.close - prev.closenumeric projectionDifference between the current and prior close.

This query self-joins AAPL rows to the same table seven days earlier to calculate a rolling price delta.

SELECT
    cur.symbol,
    cur.date,
    ROUND(cur.close::numeric, 2) AS current_close,
    ROUND(prev.close::numeric, 2) AS prev_close,
    ROUND((cur.close - prev.close)::numeric, 2) AS seven_day_change
FROM silver.stoxxusa50_ohlcv AS cur
JOIN silver.stoxxusa50_ohlcv AS prev
  ON prev.symbol = cur.symbol
 AND prev.date = cur.date - INTERVAL '7 day'
WHERE cur.symbol = 'AAPL'
  AND cur.date >= DATE '2026-02-02'
ORDER BY cur.date
LIMIT 5;
symboldatecurrent_closeprev_closeseven_day_change
AAPL2026-02-02270.01255.4114.60
AAPL2026-02-03269.48258.2711.21
AAPL2026-02-04276.49256.4420.05
AAPL2026-02-05275.91258.2817.63
AAPL2026-02-06278.12259.4818.64

The table appears twice, but the aliases make the roles distinct: cur is the current trading day and prev is the seven-days-prior anchor. That is the operational essence of a self join.

Semi-Joins, Anti-Joins, and Subqueries

Many business questions are not “bring me columns from both sides” questions. They are presence, absence, or row-wise comparison questions. PostgreSQL answers those most clearly with EXISTS, NOT EXISTS, scalar subqueries with guaranteed cardinality, and correlated subqueries where an outer row needs its own comparison set.

Presence and absence tests

Semi-joins return left-side rows when a related row exists. Anti-joins return left-side rows only when no related row exists. These patterns are about truth tests, not enrichment, so EXISTS and NOT EXISTS are usually clearer than join-plus-deduplication workarounds.

Use EXISTS when only row presence matters

Use EXISTS when the question is “does at least one related row exist?” and the query does not need right-side columns. It is typically triggered by gating logic, validation queries, and membership checks. The query is read-only. Its purpose is to show the clean semi-join pattern on the warehouse index dimension.

FieldSourceTypeMeaning
index_keybronze.dim_index.index_keyvarcharDimension key returned only when at least one current score row exists.

This query returns only those dimension keys that have at least one score row on the latest scoring date.

SELECT
    d.index_key
FROM bronze.dim_index AS d
WHERE EXISTS (
    SELECT 1
    FROM gold.scores_daily AS s
    WHERE s._index = d.index_key
      AND s.score_date = (SELECT MAX(score_date) FROM gold.scores_daily)
)
ORDER BY d.index_key;
index_key
euro_stoxx_50
oil_20
stoxx_asia_50
stoxx_usa_50

EXISTS stops at the logical question of presence. It does not multiply rows by the number of matches, and it does not need a DISTINCT cleanup step afterward.

Use NOT EXISTS for absence checks

Use NOT EXISTS when the business question is “which requested keys do not have a related row?” It is typically triggered by reconciliation, exception queues, and load-gap checks. The query is read-only. Its purpose is to show the standard PostgreSQL anti-join idiom.

FieldSourceTypeMeaning
index_keyinline VALUES rowsettextCandidate key returned only when no current performance row exists.

This query returns requested keys that do not appear in the latest performance snapshot.

WITH candidate(index_key) AS (
    VALUES ('euro_stoxx_50'), ('oil_20'), ('crypto_10'), ('frontier_15')
)
SELECT
    c.index_key
FROM candidate AS c
WHERE NOT EXISTS (
    SELECT 1
    FROM gold.index_performance AS p
    WHERE p._index = c.index_key
      AND p.perf_date = (SELECT MAX(perf_date) FROM gold.index_performance)
)
ORDER BY c.index_key;
index_key
crypto_10
frontier_15

This query expresses absence directly. That makes it safer than NOT IN patterns that can be derailed by nullable right-side values.

Scalar and correlated subqueries

Subqueries are still useful in PostgreSQL, but the operational rule is strict: a scalar subquery must return at most one row for each outer row. If it returns zero rows, PostgreSQL yields NULL. If it returns more than one row, PostgreSQL raises an error.

A scalar subquery can safely return one derived value per outer row

Use a scalar subquery when one outer row needs one derived value and that value is naturally computed inside a compact inner query. It is typically triggered by lookup-style projections or per-row metadata rollups. The query is read-only. Its purpose is to show a safe scalar subquery that guarantees one row through MAX(...).

FieldSourceTypeMeaning
_indexgold.index_performance._indexvarcharIndex key from the outer row.
perf_dategold.index_performance.perf_datedateLatest performance date of the outer row.
latest_score_datescalar subquery over gold.scores_dailydateMost recent score date available for the same index.

This query decorates each latest performance row with the most recent score date for that index.

SELECT
    p._index,
    p.perf_date,
    (
        SELECT MAX(s.score_date)
        FROM gold.scores_daily AS s
        WHERE s._index = p._index
    ) AS latest_score_date
FROM gold.index_performance AS p
WHERE p.perf_date = (SELECT MAX(perf_date) FROM gold.index_performance)
ORDER BY p._index;
_indexperf_datelatest_score_date
euro_stoxx_502026-04-072026-04-08
oil_202026-04-072026-04-08
stoxx_asia_502026-04-072026-04-08
stoxx_usa_502026-04-072026-04-08

The scalar query is safe because MAX(...) collapses any matching score rows to one value. That is the core cardinality safeguard for scalar subqueries.

A scalar subquery that finds no rows yields NULL

Use this pattern when the absence of a related row is meaningful and a nullable scalar result is acceptable. It is typically triggered by optional metadata lookups or audit queries where “missing” is itself a useful state. The query is read-only. Its purpose is to show PostgreSQL’s zero-row scalar behavior explicitly.

FieldSourceTypeMeaning
index_keyinline VALUES rowsettextCandidate key being checked.
latest_perf_datescalar subquery over gold.index_performancedateLatest performance date when one exists; otherwise NULL.

This query shows that a scalar subquery returns NULL when no matching rows exist.

WITH candidate(index_key) AS (
    VALUES ('euro_stoxx_50'), ('crypto_10')
)
SELECT
    c.index_key,
    (
        SELECT MAX(p.perf_date)
        FROM gold.index_performance AS p
        WHERE p._index = c.index_key
    ) AS latest_perf_date
FROM candidate AS c
ORDER BY c.index_key;
index_keylatest_perf_date
crypto_10
euro_stoxx_502026-04-07

That NULL is not an error. It is PostgreSQL’s normal result for “no scalar value could be produced.”

A scalar subquery that returns multiple rows fails

Use this example as a correctness boundary during review or debugging. It is typically triggered when a query author assumes uniqueness that the data does not actually enforce. The query is read-only, but it fails at execution time. Its purpose is to show the exact PostgreSQL error when a scalar position receives more than one row.

FieldSourceTypeMeaning
symbolgold.scores_daily.symbolvarcharEquity ticker returned by the inner query. The problem is that many rows satisfy the filter.

This query fails because the inner query produces many symbols, not one scalar value.

SELECT (
    SELECT symbol
    FROM gold.scores_daily
    WHERE _index = 'euro_stoxx_50'
      AND score_date = (SELECT MAX(score_date) FROM gold.scores_daily)
);
ERROR:  more than one row returned by a subquery used as an expression

When that error appears, the fix is to aggregate, limit with deterministic ordering, or redesign the query so the outer expression no longer expects a scalar.

Use a correlated subquery when each row needs its own comparison set

Use a correlated subquery when every outer row must be compared to a value derived from rows in the same logical group. It is typically triggered by per-group baselines, threshold checks, or classification work. The query is read-only. Its purpose is to compare each latest score to the average score of its own index on the same scoring date.

FieldSourceTypeMeaning
symbolgold.scores_daily.symbolvarcharEquity ticker in the selected scoring slice.
_indexgold.scores_daily._indexvarcharIndex grouping key used by the correlated subquery.
composite_scoregold.scores_daily.composite_scoredouble precisionCurrent score of the row being evaluated.
index_avgcorrelated AVG(...) subquerynumericAverage composite score of all rows in the same index and score date.

This query compares each top Euro Stoxx 50 score to the average score of its own scoring slice.

SELECT
    s.symbol,
    s._index,
    ROUND(s.composite_score::numeric, 4) AS composite_score,
    ROUND((
        SELECT AVG(s2.composite_score)::numeric
        FROM gold.scores_daily AS s2
        WHERE s2._index = s._index
          AND s2.score_date = s.score_date
    ), 4) AS index_avg
FROM gold.scores_daily AS s
WHERE s.score_date = (SELECT MAX(score_date) FROM gold.scores_daily)
  AND s._index = 'euro_stoxx_50'
ORDER BY s.composite_score DESC NULLS LAST
LIMIT 5;
symbol_indexcomposite_scoreindex_avg
BNP.PAeuro_stoxx_500.59670.0200
TTE.PAeuro_stoxx_500.49540.0200
ENI.MIeuro_stoxx_500.48070.0200
VOW.DEeuro_stoxx_500.46060.0200
DTE.DEeuro_stoxx_500.38050.0200

The inner query is correlated because it depends on the outer row’s _index and score_date. That per-row dependency is what makes the comparison group-specific.

LATERAL as PostgreSQL’s APPLY Analogue

PostgreSQL does not implement SQL Server CROSS APPLY or OUTER APPLY. The operational equivalent is LATERAL, which lets a right-side subquery reference columns from the left-side row currently being processed. CROSS JOIN LATERAL behaves like CROSS APPLY, and LEFT JOIN LATERAL ... ON TRUE behaves like OUTER APPLY.

Row-wise derived rowsets

Use LATERAL when a derived right-side rowset depends on the current left-side row and the query would otherwise become awkward or repetitive. The classic cases are per-parent top-N, row-wise expansion, and optional derived lookups.

Return the top scored symbols for each selected index

Use CROSS JOIN LATERAL when each left-side row should produce one or more derived right-side rows and left rows with no derived result can be discarded. It is typically triggered by top-N-per-group work, row-wise ranking, and parameterized subqueries. The query is read-only. Its purpose is to show the PostgreSQL pattern that replaces SQL Server CROSS APPLY.

FieldSourceTypeMeaning
index_keybronze.dim_index.index_keyvarcharLeft-side parent key.
symbolgold.scores_daily.symbolvarcharSymbol returned by the row-wise top-N subquery.
composite_scoregold.scores_daily.composite_scoredouble precisionScore used to rank symbols within the index.
composite_rankgold.scores_daily.composite_ranksmallintExisting rank value from the scoring table.

This query runs a per-index top-two scoring query by using LATERAL to bind the inner query to each outer index row.

SELECT
    i.index_key,
    top_scores.symbol,
    ROUND(top_scores.composite_score::numeric, 4) AS composite_score,
    top_scores.composite_rank
FROM bronze.dim_index AS i
CROSS JOIN LATERAL (
    SELECT
        s.symbol,
        s.composite_score,
        s.composite_rank
    FROM gold.scores_daily AS s
    WHERE s._index = i.index_key
      AND s.score_date = (
          SELECT MAX(score_date)
          FROM gold.scores_daily
          WHERE _index = i.index_key
      )
    ORDER BY s.composite_score DESC NULLS LAST
    LIMIT 2
) AS top_scores
WHERE i.index_key IN ('euro_stoxx_50', 'stoxx_usa_50')
ORDER BY i.index_key, top_scores.composite_score DESC;
index_keysymbolcomposite_scorecomposite_rank
euro_stoxx_50BNP.PA0.59671
euro_stoxx_50TTE.PA0.49542
stoxx_usa_50MU1.36171
stoxx_usa_50AMD0.54082

The inner subquery is evaluated in the context of each outer index_key. That row-wise dependency is the defining feature of LATERAL.

Preserve left rows when the row-wise lookup is optional

Use LEFT JOIN LATERAL ... ON TRUE when the left-side rowset must survive even if the row-wise lookup finds nothing. It is typically triggered by optional metadata, optional latest-state lookups, or coverage reporting. The query is read-only. Its purpose is to show PostgreSQL’s outer-preserving LATERAL pattern.

FieldSourceTypeMeaning
index_keyinline VALUES rowsettextRequested key that must remain visible.
perf_daterow-wise lookup from gold.index_performancedateLatest performance date if present.
daily_returnrow-wise lookup from gold.index_performancenumericRounded daily return if present.

This query keeps the requested key list and only fills in the latest performance row when one exists.

WITH candidate(index_key) AS (
    VALUES ('euro_stoxx_50'), ('oil_20'), ('crypto_10')
)
SELECT
    c.index_key,
    latest.perf_date,
    latest.daily_return
FROM candidate AS c
LEFT JOIN LATERAL (
    SELECT
        p.perf_date,
        ROUND(p.daily_return::numeric, 6) AS daily_return
    FROM gold.index_performance AS p
    WHERE p._index = c.index_key
    ORDER BY p.perf_date DESC
    LIMIT 1
) AS latest
  ON TRUE
ORDER BY c.index_key;
index_keyperf_datedaily_return
crypto_10
euro_stoxx_502026-04-07-0.007931
oil_202026-04-070.006887

This is the closest PostgreSQL equivalent to SQL Server OUTER APPLY: the left row remains visible, and the derived right-side columns become NULL when no row qualifies.

Set Operators

Set operators combine complete rowsets branch by branch rather than matching rows side by side. PostgreSQL supports the standard operators UNION, UNION ALL, EXCEPT, and INTERSECT. The main operational decisions are whether duplicates should be preserved and whether the comparison is about concatenation, difference, or overlap.

Combining and comparing complete result sets

Every set-operator branch must project the same number of columns in compatible positions. Once that requirement is satisfied, the main difference is whether duplicates are preserved.

Prefer UNION ALL unless deduplication is a real requirement

Use UNION ALL when the query is concatenating compatible rowsets and duplicate preservation is correct or harmless. It is typically triggered by append-style reporting, staging union patterns, and audit comparisons across dates. The query is read-only. Its purpose is to show that UNION is not a free synonym for UNION ALL; it adds duplicate elimination work and changes row counts.

FieldSourceTypeMeaning
union_all_rowscount over UNION ALL branchesbigintRow count when both branches are appended as-is.
union_rowscount over UNION branchesbigintRow count after duplicate elimination.

This query compares the row counts produced by UNION ALL and UNION across two nearby performance dates.

WITH latest AS (
    SELECT _index
    FROM gold.index_performance
    WHERE perf_date = DATE '2026-04-07'
),
prior AS (
    SELECT _index
    FROM gold.index_performance
    WHERE perf_date = DATE '2026-04-06'
)
SELECT
    (SELECT COUNT(*) FROM (SELECT _index FROM latest UNION ALL SELECT _index FROM prior) AS q) AS union_all_rows,
    (SELECT COUNT(*) FROM (SELECT _index FROM latest UNION SELECT _index FROM prior) AS q) AS union_rows;
union_all_rowsunion_rows
74

The duplicate elimination collapsed the seven branch rows to four distinct index keys. That is the right behavior only when deduplication is part of the business requirement.

Use EXCEPT and INTERSECT for difference and overlap

Use EXCEPT when the goal is “left set minus right set” and INTERSECT when the goal is “only rows common to both sets.” It is typically triggered by reconciliation, feed-difference checks, and membership comparisons. The queries are read-only. Their purpose is to express set logic directly instead of rebuilding it with joins.

FieldSourceTypeMeaning
index_keycandidate branch or fact branchtextBusiness key being compared across both sets.

This query returns candidate keys that are absent from the latest observed performance set.

WITH candidate(index_key) AS (
    VALUES ('euro_stoxx_50'), ('oil_20'), ('frontier_15')
)
SELECT index_key
FROM candidate
EXCEPT
SELECT _index
FROM gold.index_performance
WHERE perf_date = (SELECT MAX(perf_date) FROM gold.index_performance)
ORDER BY index_key;
index_key
frontier_15

This query returns only the candidate keys that overlap with the latest observed performance set.

WITH candidate(index_key) AS (
    VALUES ('euro_stoxx_50'), ('oil_20'), ('frontier_15')
)
SELECT index_key
FROM candidate
INTERSECT
SELECT _index
FROM gold.index_performance
WHERE perf_date = (SELECT MAX(perf_date) FROM gold.index_performance)
ORDER BY index_key;
index_key
euro_stoxx_50
oil_20

These operators express the business question directly. EXCEPT is the missing-key view. INTERSECT is the shared-key view.

Pivot and Unpivot Equivalents

PostgreSQL does not have SQL Server PIVOT and UNPIVOT keywords in core SQL. The operational replacement is usually simpler anyway: use conditional aggregates such as FILTER for row-to-column transposition, and use CROSS JOIN LATERAL (VALUES ...) for column-to-row expansion. When a fully dynamic cross-tab report is required, tablefunc.crosstab() is the extension-based alternative, but most pipeline SQL does not need it.

Row-to-column and column-to-row reshaping

These patterns matter when analytical outputs need report-like shapes rather than normalized rowsets. The key is to keep the transformation explicit so column definitions and null behavior remain obvious.

Pivot rows into columns with conditional aggregates

Use conditional aggregates when a fixed set of output columns is known and the goal is to transpose grouped rows into one row per key. It is typically triggered by compact report output, side-by-side metric comparison, and notebook summary tables. The query is read-only. Its purpose is to show the PostgreSQL-native pivot pattern without any special keyword.

FieldSourceTypeMeaning
perf_dategold.index_performance.perf_datedateGrouping key that becomes one output row per date.
euro_stoxx_50filtered aggregate on daily_returnnumericDaily return for Euro Stoxx 50 on that date when present.
stoxx_usa_50filtered aggregate on daily_returnnumericDaily return for STOXX USA 50 on that date when present.

This query pivots two index return series into separate output columns by using aggregate FILTER clauses.

SELECT
    perf_date,
    ROUND((MAX(daily_return) FILTER (WHERE _index = 'euro_stoxx_50'))::numeric, 6) AS euro_stoxx_50,
    ROUND((MAX(daily_return) FILTER (WHERE _index = 'stoxx_usa_50'))::numeric, 6) AS stoxx_usa_50
FROM gold.index_performance
WHERE perf_date BETWEEN DATE '2026-04-02' AND DATE '2026-04-07'
GROUP BY perf_date
ORDER BY perf_date;
perf_dateeuro_stoxx_50stoxx_usa_50
2026-04-02-0.0047970.000955
2026-04-03
2026-04-060.004856
2026-04-07-0.0079310.000962

The NULL cells are informative: they mean no qualifying row existed for that index on that date. Unlike a hidden pivot operator, the aggregation logic and null behavior remain explicit in the query text.

Unpivot columns into rows with CROSS JOIN LATERAL (VALUES ...)

Use VALUES row expansion when a query needs to turn several named columns into repeated metric rows. It is typically triggered by metric audits, generic chart feeds, and compact attribute inspection. The query is read-only. Its purpose is to show the clean PostgreSQL replacement for SQL Server UNPIVOT.

FieldSourceTypeMeaning
_indexgold.index_performance._indexvarcharBusiness key carried from the source row.
perf_dategold.index_performance.perf_datedateDate of the source metric row.
metric_namelabel column from VALUEStextHuman-readable metric label emitted by the row constructor.
metric_valuemetric column from VALUESnumericRounded metric value emitted by the row constructor.

This query turns three performance columns into a labeled rowset for one latest index row.

SELECT
    p._index,
    p.perf_date,
    m.metric_name,
    m.metric_value
FROM (
    SELECT
        _index,
        perf_date,
        daily_return,
        rolling_30d_return,
        ytd_return
    FROM gold.index_performance
    WHERE _index = 'euro_stoxx_50'
      AND perf_date = (SELECT MAX(perf_date) FROM gold.index_performance)
) AS p
CROSS JOIN LATERAL (
    VALUES
        ('daily_return', ROUND(p.daily_return::numeric, 6)),
        ('rolling_30d_return', ROUND(p.rolling_30d_return::numeric, 6)),
        ('ytd_return', ROUND(p.ytd_return::numeric, 6))
) AS m(metric_name, metric_value)
ORDER BY m.metric_name;
_indexperf_datemetric_namemetric_value
euro_stoxx_502026-04-07daily_return-0.007931
euro_stoxx_502026-04-07rolling_30d_return-0.069259
euro_stoxx_502026-04-07ytd_return-0.040304

This pattern is more flexible than UNPIVOT because it can mix arbitrary expressions, keep control over labels, and decide explicitly which columns should become rows.

Reference Map

The operational translation from SQL Server to PostgreSQL is straightforward once the rowset role is clear.

SQL Server conceptPostgreSQL analoguePractical rule
INNER, LEFT, RIGHT, FULL OUTER JOINSame syntaxPrefer LEFT JOIN with the driving rowset written on the left.
EXISTS / NOT EXISTSSame syntaxUse these for semi-joins and anti-joins instead of join-plus-deduplication patterns.
Scalar subquerySame semanticsGuarantee at most one row, or PostgreSQL raises an error.
CROSS APPLYCROSS JOIN LATERALUse when a derived rowset depends on the current outer row and unmatched rows may be dropped.
OUTER APPLYLEFT JOIN LATERAL ... ON TRUEUse when the outer row must survive even if the derived lookup returns nothing.
UNION, UNION ALL, EXCEPT, INTERSECTSame standard operatorsPrefer UNION ALL unless duplicate elimination is required.
PIVOTConditional aggregates with FILTER or CASEKeep the transposition explicit in the query text.
UNPIVOTCROSS JOIN LATERAL (VALUES ...)Expand chosen columns into labeled rows with full expression control.