PostgreSQL Joins

Master all PostgreSQL JOIN types — INNER, LEFT, RIGHT, FULL OUTER, CROSS, and NATURAL — with examples, comparisons, and production best practices.

12 min read · PostgreSQL 9.4+ · Back to overview

Quick Answer

PostgreSQL supports five core join types: INNER JOIN returns only matching rows; LEFT JOIN keeps all left-table rows; RIGHT JOIN keeps all right-table rows; FULL OUTER JOIN keeps all rows from both tables; CROSS JOIN returns the Cartesian product. Each controls how unmatched rows are handled.

Spin up a Postgres database in 20 seconds with Vela.

Try Vela Sandbox

Joins are the fundamental mechanism for combining rows across related tables in a relational database. PostgreSQL evaluates a join by comparing rows from two (or more) tables using a condition, then assembling a result set based on which rows matched and which did not — the join type governs that last part.

Syntax

The general form of a two-table join:

SELECT select_list
FROM table1
[JOIN_TYPE] table2
  ON table1.column = table2.column;

The INNER keyword is optional for INNER JOIN — bare JOIN means the same thing. The OUTER keyword is optional for LEFT OUTER JOIN, RIGHT OUTER JOIN, and FULL OUTER JOIN.

Practical Example

The following tables model an e-commerce domain and will be used across all join examples in this guide:

CREATE TABLE categories (
  category_id   SERIAL PRIMARY KEY,
  category_name VARCHAR(100) NOT NULL
);

CREATE TABLE products (
  product_id   SERIAL PRIMARY KEY,
  product_name VARCHAR(150) NOT NULL,
  category_id  INT REFERENCES categories(category_id),
  unit_price   NUMERIC(10, 2) NOT NULL
);

CREATE TABLE orders (
  order_id    SERIAL PRIMARY KEY,
  product_id  INT REFERENCES products(product_id),
  quantity    INT NOT NULL,
  ordered_at  TIMESTAMPTZ DEFAULT now()
);

INSERT INTO categories (category_name)
VALUES ('Electronics'), ('Furniture'), ('Apparel'), ('Seasonal');

INSERT INTO products (product_name, category_id, unit_price)
VALUES
  ('Noise-cancelling headphones', 1, 199.00),
  ('Standing desk',               2, 549.00),
  ('Merino wool sweater',         3,  89.00),
  ('USB-C hub',                   1,  49.00),
  ('Clearance bin item',          NULL, 5.00);  -- no category assigned

INSERT INTO orders (product_id, quantity)
VALUES (1, 2), (3, 1), (3, 3), (4, 5);
-- product 2 (standing desk) has no orders
-- product 5 (clearance bin) has no orders

INNER JOIN — matched rows only

SELECT
  p.product_name,
  c.category_name,
  o.quantity
FROM orders o
INNER JOIN products p ON p.product_id = o.product_id
INNER JOIN categories c ON c.category_id = p.category_id
ORDER BY o.order_id;

Returns only orders whose product belongs to a category. The “clearance bin item” and “standing desk” are absent because they have no orders or no category.

LEFT JOIN — preserve all orders

SELECT
  o.order_id,
  p.product_name,
  c.category_name
FROM orders o
LEFT JOIN products p ON p.product_id = o.product_id
LEFT JOIN categories c ON c.category_id = p.category_id
ORDER BY o.order_id;

Every order row is preserved. If a product has no category, category_name is NULL.

Anti-join — products with no orders

SELECT p.product_name
FROM products p
LEFT JOIN orders o ON o.product_id = p.product_id
WHERE o.order_id IS NULL;

Finds products in the catalog that have never been ordered.

Verify join column indexes

SELECT
  indexname,
  indexdef
FROM pg_indexes
WHERE tablename IN ('products', 'orders')
  AND indexdef ILIKE '%category_id%' OR indexdef ILIKE '%product_id%';

Join Types Comparison

Join typeLeft rowsRight rowsUnmatched leftUnmatched right
INNER JOINMatched onlyMatched onlyExcludedExcluded
LEFT JOINAllMatched onlyIncluded (NULLs on right)Excluded
RIGHT JOINMatched onlyAllExcludedIncluded (NULLs on left)
FULL OUTER JOINAllAllIncluded (NULLs on right)Included (NULLs on left)
CROSS JOINAllAllN/A — no conditionN/A — no condition
NATURAL JOINImplicit ON shared columnsImplicit ON shared columnsExcluded (default INNER)Excluded (default INNER)

Testing with Vela

Because joins span multiple tables, join-heavy query rewrites or index additions carry cross-table risk. Vela’s database branching lets you spin up a production-identical branch in seconds, try new join patterns or add indexes on foreign key columns, run EXPLAIN ANALYZE against real data volumes, and confirm performance before any changes land in production. This is especially valuable when profiling hash join vs. nested loop strategies on large tables.

Production Tips

  • Always index foreign key columns used in join conditions — PostgreSQL does not create these automatically, and unindexed joins cause sequential scans.
  • Qualify every column reference with a table alias in multi-table queries to eliminate ambiguity and make the intent clear to readers.
  • Avoid SELECT * in joins — duplicate column names from two tables can silently overwrite each other in application result-set mapping.
  • Use EXPLAIN ANALYZE to verify the planner chose the right strategy (hash join for large unsorted tables, merge join for pre-sorted data, nested loop for small tables).
  • Moving a right-table filter from WHERE into the ON clause preserves LEFT JOIN semantics and prevents accidentally converting the join to an inner join.
  • For anti-join patterns (NOT EXISTS, LEFT JOIN ... WHERE IS NULL), NOT EXISTS often generates a slightly more efficient plan on large datasets — benchmark both forms.

Continue in Join Tables: Table Aliases.

Related in this section: Table Aliases · PostgreSQL INNER JOIN · PostgreSQL LEFT JOIN

Frequently Asked Questions

What is the difference between INNER JOIN and LEFT JOIN in PostgreSQL?
INNER JOIN returns only rows where the ON condition matches in both tables — rows with no counterpart are excluded entirely. LEFT JOIN returns every row from the left table regardless of whether a matching row exists on the right; columns from the right table are NULL when there is no match. Use INNER JOIN for strict relational lookups and LEFT JOIN when you need to preserve every record from the primary table.
Does JOIN lock the tables being joined?
A SELECT with a JOIN acquires AccessShareLock on each table, which is the least restrictive lock in PostgreSQL. It does not block concurrent reads or writes. Only data-modification joins (UPDATE ... FROM, DELETE ... USING) acquire stronger locks on the target table.
What is the USING clause in a PostgreSQL JOIN?
USING (column_name) is a shorthand for ON t1.column = t2.column when both tables share the same column name for the join key. PostgreSQL outputs that column only once in the result instead of twice. It is slightly more concise than ON but less explicit about which table each side comes from.
How does PostgreSQL handle NULLs in join conditions?
NULL values never satisfy equality in join conditions — NULL = NULL evaluates to NULL, not TRUE. This means rows with NULL in the join column are excluded from INNER JOINs. In a LEFT JOIN, rows from the left table with NULL in the join column are still included, with right-side columns set to NULL. Use IS NOT DISTINCT FROM if you intentionally want NULL to match NULL.
What is the safest way to write joins in production PostgreSQL?
Always index the join columns (especially foreign keys), use explicit column aliases rather than SELECT *, qualify all column references with a table alias to avoid ambiguity, and run EXPLAIN ANALYZE to verify the join strategy the planner chose. For large tables, prefer HASH JOIN-friendly queries and keep statistics up to date with ANALYZE.