Joins in SQL: Inner, Outer, and Semi
Joins are one of the most fundamental concepts in SQL, yet they are frequently misunderstood. The differences between inner joins, outer joins, and semi-joins can be subtle, especially when conditions and aggregates are involved. This article clarifies how each join type behaves using PostgreSQL syntax and examples.
Implicit vs. explicit join syntax
Before examining join types, it helps to understand the two ways to write a join. An implicit join lists tables in the FROM clause and places the join condition in the WHERE clause:
PgSQL
|
1 2 3 4 5 6 7 8 |
test=# SELECT * FROM a, b WHERE aid = bid; aid | bid -----+----- 2 | 2 3 | 3 4 | 4 4 | 4 (4 rows) |
An explicit join uses the JOIN keyword and puts the condition in the ON clause:
PgSQL
|
1 2 3 4 5 6 7 8 |
test=# SELECT * FROM a JOIN b ON (aid = bid); aid | bid -----+----- 2 | 2 3 | 3 4 | 4 4 | 4 (4 rows) |
These two queries are semantically identical. PostgreSQL's optimizer generates the same execution plan for both in most cases. However, there is one configuration detail worth noting. The join_collapse_limit setting in postgresql.conf controls how many explicit joins the planner will reorder:
PgSQL
|
1 2 3 4 5 |
test=# SHOW join_collapse_limit; join_collapse_limit --------------------- 8 (1 row) |
With the default value, the optimizer can reorder up to 8 explicit joins freely. Beyond that, explicit joins are processed in the order written in the query. Implicit joins, on the other hand, are always subject to automatic reordering. In practice, this rarely matters for typical applications, but it becomes relevant for very complex queries with many tables.
Inner vs. outer joins
The core distinction is straightforward: an inner join returns only rows that have matching entries on both sides. An outer join always returns all rows from one side (or both), filling in NULL values where no match exists on the other side.
Consider a LEFT JOIN, which takes all rows from the left table and finds matches on the right:
PgSQL
|
1 2 3 4 5 6 7 8 9 |
test=# SELECT * FROM a LEFT JOIN b ON (aid = bid); aid | bid -----+----- 1 | 2 | 2 3 | 3 4 | 4 4 | 4 (5 rows) |
The mirror image is a RIGHT JOIN, which behaves the same way but with the roles of the tables swapped:
PgSQL
|
1 2 3 4 5 6 7 8 9 |
test=# SELECT * FROM b RIGHT JOIN a ON (aid = bid); bid | aid -----+----- | 1 2 | 2 3 | 3 4 | 4 4 | 4 (5 rows) |
A FULL JOIN takes all rows from both sides. Rows without a counterpart on the opposite side are padded with NULL values:
PgSQL
|
1 2 3 4 5 6 7 8 9 10 |
test=# SELECT * FROM a FULL JOIN b ON (aid = bid); aid | bid -----+----- 1 | 2 | 2 3 | 3 4 | 4 4 | 4 | 5 (6 rows) |
Common outer join pitfalls
A frequent mistake is adding extra conditions to the ON clause of an outer join, expecting the result set to shrink. This does not happen. The ON condition only determines which rows match; an outer join still produces all rows from the preserved side. Consider this query:
PgSQL
|
1 2 3 4 5 6 7 8 9 10 |
test=# SELECT * FROM a LEFT JOIN b ON (aid = bid AND bid = 2); aid | bid -----+----- 1 | 2 | 2 3 | 4 | (4 rows) |
This query returns the same number of rows as the unmodified left join. The additional condition merely turns some matching values into NULL. The result set is not filtered. This behavior often surfaces as an apparent performance problem or, worse, as incorrect results when combined with aggregate functions:
PgSQL
|
1 2 3 4 5 6 7 |
test=# SELECT count(*), count(bid) FROM a LEFT JOIN b ON (aid = bid AND bid = 2); count | count -------+------- 4 | 1 (1 row) |
Users expect certain rows to be excluded by the extra condition, but outer join semantics do not work that way. If filtering is the goal, the condition belongs in the WHERE clause.
Semi-joins and the IN operator
A semi-join is conceptually different from a regular join. Consider an IN subquery:
PgSQL
|
1 2 3 4 5 6 7 |
test=# SELECT * FROM a WHERE aid IN (SELECT bid FROM b); aid ----- 2 3 4 (3 rows) |
The IN operator acts as an implicit DISTINCT filter. The execution plan shows this duplicate elimination, often through a HashAggregate node:
PgSQL
|
1 2 3 4 5 6 7 8 9 10 11 |
test=# explain SELECT * FROM a WHERE aid IN (SELECT bid FROM b); QUERY PLAN ----------------------------------------------------------------------- Hash Join (cost=46.38..102.75 rows=1275 width=4) Hash Cond: (a.aid = b.bid) -> Seq Scan on a (cost=0.00..35.50 rows=2550 width=4) -> Hash (cost=43.88..43.88 rows=200 width=4) -> HashAggregate (cost=41.88..43.88 rows=200 width=4) Group Key: b.bid -> Seq Scan on b (cost=0.00..35.50 rows=2550 width=4) (7 rows) |
The counterpart, NOT IN, removes rows that match any value in the subquery:
PgSQL
|
1 2 3 4 5 |
test=# SELECT * FROM a WHERE aid NOT IN (SELECT bid FROM b); aid ----- 1 (1 row) |
Understanding whether a query needs duplicate elimination or full row preservation is the key to choosing the right join type and avoiding subtle bugs.



