PostgreSQL NATURAL JOIN

PostgreSQL NATURAL JOIN automatically joins tables on all columns with matching names. Learn its syntax, default behavior, risks, and when to prefer JOIN USING instead.

4 min read · PostgreSQL 9.4+ · Back to overview

Quick Answer

NATURAL JOIN creates an implicit join condition based on all columns that share the same name in both tables. It requires no ON or USING clause, but it is fragile — any future schema change that adds a shared column name silently alters the join behavior without a syntax error.

Spin up a Postgres database in 20 seconds with Vela.

Try Vela Sandbox

NATURAL JOIN creates an implicit join condition by automatically matching all column names that appear in both tables. You do not write an ON or USING clause — PostgreSQL infers the condition from the schema at query planning time. While this can make simple queries more concise, it is fragile in production code because any schema change that introduces a new shared column name silently alters the join.

Syntax

SELECT select_list
FROM table1
NATURAL [INNER | LEFT | RIGHT] JOIN table2;

Without an explicit join-type keyword, NATURAL JOIN defaults to NATURAL INNER JOIN. The shared column(s) appear only once in the output — not duplicated from both tables.

Equivalent explicit forms:

-- NATURAL INNER JOIN is equivalent to:
SELECT select_list FROM t1 INNER JOIN t2 USING (shared_col);

-- NATURAL LEFT JOIN is equivalent to:
SELECT select_list FROM t1 LEFT JOIN t2 USING (shared_col);

Practical Example

The following schema tracks software releases and the build pipelines that produce them. Both tables share a pipeline_id column that acts as the natural join key.

CREATE TABLE build_pipelines (
  pipeline_id   SERIAL PRIMARY KEY,
  pipeline_name VARCHAR(100) NOT NULL,
  environment   VARCHAR(30)   NOT NULL   -- 'staging', 'production'
);

CREATE TABLE release_artifacts (
  artifact_id   SERIAL PRIMARY KEY,
  pipeline_id   INT REFERENCES build_pipelines(pipeline_id),
  version_tag   VARCHAR(30)  NOT NULL,
  artifact_size BIGINT,
  built_at      TIMESTAMPTZ DEFAULT now()
);

INSERT INTO build_pipelines (pipeline_name, environment) VALUES
  ('core-api-build',     'production'),
  ('frontend-build',     'production'),
  ('integration-tests',  'staging');

INSERT INTO release_artifacts (pipeline_id, version_tag, artifact_size) VALUES
  (1, 'v2.4.0', 52428800),
  (1, 'v2.4.1', 52530000),
  (2, 'v1.9.0', 12582912),
  (2, 'v1.9.1', 12600000);
-- pipeline 3 (integration-tests) has no artifacts

Basic NATURAL JOIN

SELECT
  pipeline_name,
  environment,
  version_tag,
  artifact_size
FROM build_pipelines
NATURAL JOIN release_artifacts
ORDER BY pipeline_name, version_tag;

PostgreSQL automatically joins on pipeline_id because it is the only column that appears in both tables. pipeline_id appears once in the output.

NATURAL LEFT JOIN — include pipelines with no artifacts

SELECT
  pipeline_name,
  environment,
  version_tag
FROM build_pipelines
NATURAL LEFT JOIN release_artifacts
ORDER BY pipeline_name;

The integration-tests pipeline (no artifacts) now appears with NULL in version_tag.

Inspect what columns NATURAL JOIN will use

Before running a NATURAL JOIN, check the shared column names to confirm the implicit condition is what you intend:

SELECT column_name
FROM information_schema.columns
WHERE table_name = 'build_pipelines'
  AND column_name IN (
    SELECT column_name
    FROM information_schema.columns
    WHERE table_name = 'release_artifacts'
  );

NATURAL JOIN vs JOIN USING vs INNER JOIN ON

NATURAL JOINJOIN USING (col)INNER JOIN ... ON
Join columns specified?No — inferred from schemaYes — explicitYes — explicit
Shared column in outputOnceOnceTwice (unless aliased)
Affected by schema changes?Yes — silentlyNoNo
Supports non-equality conditionsNoNoYes
Recommended for production?NoYesYes

Why NATURAL JOIN Can Fail Silently

Adding a column updated_at to both tables for audit purposes creates a new shared column name that NATURAL JOIN will silently incorporate:

ALTER TABLE build_pipelines   ADD COLUMN updated_at TIMESTAMPTZ DEFAULT now();
ALTER TABLE release_artifacts ADD COLUMN updated_at TIMESTAMPTZ DEFAULT now();

After this migration, NATURAL JOIN joins on both pipeline_id AND updated_at. Because the two updated_at timestamps are unlikely to match exactly, most rows return zero results — with no error or warning. This is why JOIN USING (pipeline_id) is the safer choice.

Testing with Vela

Because NATURAL JOIN is sensitive to schema changes, it is a prime candidate for testing on a branch before deploying a migration. Vela’s database branching lets you add a column to both tables on a production-data branch, run the affected queries, and verify that NATURAL JOIN behavior has not changed unexpectedly — before the migration reaches production.

Production Tips

  • Prefer INNER JOIN ... ON or JOIN USING (column) over NATURAL JOIN for all production queries — they are explicit, stable, and unaffected by future schema additions.
  • Before using NATURAL JOIN on any pair of tables, query information_schema.columns to confirm exactly which columns are shared.
  • NATURAL JOIN removes the SELECT * column duplication that ON joins produce for shared columns — if that is the only benefit you need, use JOIN USING instead for the same deduplication with explicit safety.
  • An empty result from a NATURAL JOIN on tables you expect to match is almost always caused by an unintended shared column name — inspect with \d tablename in psql to check.
  • If you inherit a codebase that uses NATURAL JOIN, migrate to explicit JOIN USING or JOIN ... ON during your next refactoring pass to eliminate the fragility.
  • Document any intentional use of NATURAL JOIN with a comment explaining which column(s) it is currently joining on, so the implicit assumption is visible to future maintainers.

Continue in Join Tables: Back to tutorial overview.

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

Frequently Asked Questions

How does PostgreSQL NATURAL JOIN decide which columns to join on?
PostgreSQL inspects the column names of both tables at query planning time and automatically builds an equality condition for every column that appears in both tables with the same name. If the two tables share both a category_id and an updated_at column, NATURAL JOIN joins on both — which may produce an empty or unexpected result.
Does NATURAL JOIN lock the tables?
A SELECT with NATURAL JOIN acquires AccessShareLock on each table, the same non-blocking lock as any read query. The locking behavior is no different from an explicit INNER JOIN or JOIN USING.
What is the default join type for NATURAL JOIN?
Without an explicit keyword, NATURAL JOIN defaults to NATURAL INNER JOIN — only rows with a match on all shared column names are returned. You can write NATURAL LEFT JOIN or NATURAL RIGHT JOIN to preserve all rows from one side.
What is the difference between NATURAL JOIN and JOIN USING?
NATURAL JOIN automatically detects all shared column names and joins on every one of them. JOIN USING (column_name) lets you specify exactly which shared column to join on. JOIN USING is safer and more maintainable because it is explicit, unaffected by future schema additions, and documents your intent clearly.
Should I use NATURAL JOIN in production SQL?
Generally no. NATURAL JOIN is fragile: adding a column to either table with a name that already exists in the other table silently changes the join condition, potentially causing incorrect results or empty result sets with no error. Prefer INNER JOIN ... ON or JOIN ... USING for all production queries.