Join / semi anti · All operations

Keep rows found in another table

Keep or drop rows by existence in another table.

Status

In arcform

Runs as a SQL step

arcform carries it as a sql: step today.

Shape of the result
one table from two or more
Level
one clause of SQL

The DuckDB step

Customers with no orders

It reads the orders and customers tables the proofs share. Create them first:

SQL
CREATE TABLE orders AS SELECT * FROM (VALUES
  (1,  'Alice Ng',      'north', DATE '2026-01-03', 120.00, 'widget', 'a|b',   '{"channel":"web","items":[1,2]}'),
  (2,  'alice ng',      'north', DATE '2026-01-05',  80.50, 'gadget', 'b',     '{"channel":"shop","items":[3]}'),
  (3,  'Bob  Okafor',   'south', DATE '2026-01-05', 300.00, 'widget', 'a|c',   '{"channel":"web","items":[4,5,6]}'),
  (4,  'Carla Díaz',    'east',  DATE '2026-01-09',  NULL,  'gizmo',  NULL,    '{"channel":"web","items":[]}'),
  (5,  'Bob Okafor',    'south', DATE '2026-01-12',  45.25, 'gadget', 'c',     '{"channel":"phone","items":[7]}'),
  (6,  'Dev Patel',     'west',  DATE '2026-01-12', 510.00, 'widget', 'a',     '{"channel":"web","items":[8,9]}'),
  (7,  'Carla Diaz',    'east',  DATE '2026-01-20',  99.99, 'gizmo',  'b|c',   '{"channel":"shop","items":[10]}'),
  (8,  'Dev Patel',     'west',  DATE '2026-02-02', 510.00, 'widget', 'a',     '{"channel":"web","items":[8,9]}'),
  (9,  'Eve Larsen',    NULL,    DATE '2026-02-03',  15.00, 'gadget', NULL,    '{"channel":"web","items":[11]}'),
  (10, 'Alice Ng',      'north', DATE '2026-02-10', 220.00, 'gizmo',  'a|b|c', '{"channel":"phone","items":[12,13]}')
) t(order_id, customer, region, order_date, amount, product, tags, meta);

CREATE TABLE customers AS SELECT * FROM (VALUES
  ('Alice Ng',    'retail',    DATE '2025-06-01', 52.52, 13.40),
  ('Bob Okafor',  'wholesale', DATE '2025-09-15', 48.85,  2.35),
  ('Carla Diaz',  'retail',    DATE '2025-11-30', 40.42, -3.70),
  ('Frank Moore', 'retail',    DATE '2026-01-01', 51.51, -0.13)
) t(customer, segment, joined, lat, lon);

Then run the statement:

SQL
CREATE TEMP TABLE result AS
SELECT * FROM customers ANTI JOIN orders USING (customer);
SELECT * FROM result;
SELECT CASE WHEN ((SELECT list(customer) = ['Frank Moore'] FROM result) AND (SELECT count(*) = 3 FROM customers SEMI JOIN orders USING (customer))) THEN 'as expected' ELSE error('the customers without and with orders are not Frank Moore and the other three') END AS verdict;
  • It needs nothing installed beyond DuckDB itself.
  • The statement checks its own result and raises an error when it is wrong: “the customers without and with orders are not Frank Moore and the other three”.
  • It passed on DuckDB v2.0.0 (Cyanoptera) d54cbbe3df.

Variants

Keep rows with a match

Keep the rows whose key appears in another table, with their own columns only.

Split rows by whether they match

Send matching rows to one output and the rest to another.

What other tools call it

Meridian's classification; an independent blind rating agreed on 86.2% of 312 rows (exact class, version 2).

Apache DataFusion (6)
  • LEFT ANTI JOINread 2026-09-27

    Left rows that have no match on the right

  • LEFT SEMI JOINread 2026-09-27

    Left rows that have at least one match on the right; left columns only

  • RIGHT ANTI JOINread 2026-09-27

    Right rows that have no match on the left

  • RIGHT SEMI JOINread 2026-09-27

    Right rows that have at least one match on the left; right columns only

  • [ NOT ] EXISTSread 2026-09-27

    Keep rows for which a correlated subquery returns, or does not return, any row

  • [ NOT ] INread 2026-09-27

    Keep rows whose value is, or is not, found in the subquery result

Apache NiFi (2)
  • ScanAttributeread 2026-09-27

    Check attribute values against a dictionary of terms and route by presence

  • ScanContentread 2026-09-27

    Search content for terms from a user-supplied dictionary and route by match

Azure Data Factory (1)
  • Existsread 2026-09-27

    Keep rows by whether they exist, or do not exist, in another stream

Duckle (2)
  • Anti Joinread 2026-09-25

    keep rows whose key is absent from the other input

  • Semi Joinread 2026-09-25

    keep rows whose key exists in the other input

EasyMorph (1)
  • Keep/remove matchingread 2026-09-27

    Keep or remove rows whose value exists in a column of another table

Ibis (2)
  • ExistsSubqueryread 2026-09-27

    Test whether a subquery returns any row

  • InSubqueryread 2026-09-27

    Test whether a value appears in the result of a subquery

KNIME Analytics Platform (2)
Power Query M (1)
RapidMiner Studio (2)
  • Intersectread 2026-09-27

    Keep rows of the first table whose ID also appears in the second table

  • Set Minusread 2026-09-27

    Keep rows of the first table whose ID does not appear in the second table

Tableau Prep (3)
  • leftOnlyread 2026-09-27

    Rows of the left table with no match in the right table

  • notInnerread 2026-09-27

    Rows of both tables that have no match in the other

  • rightOnlyread 2026-09-27

    Rows of the right table with no match in the left table

VisiData (4)
  • join-cols-diffread 2026-09-23

    Join on selected columns: keep rows whose key is missing from at least one sheet

  • join-selected-diffread 2026-09-23

    Join selected sheets: keep rows whose key is missing from at least one sheet

  • join-sheets-all-diffread 2026-09-23

    Join all stacked sheets: keep rows whose key is missing from at least one sheet

  • join-sheets-top2-diffread 2026-09-23

    Join top two sheets: keep rows whose key is missing from at least one sheet

Background

A semijoin keeps the rows of one table that match a row of another on their common columns, and an antijoin keeps the rows that match none.[1] A semijoin differs from a natural join in that the other table's columns do not appear in its result.[1] In DuckDB, the result of a semi or anti join never has more rows than the left-hand table.[2]

When to use it

Reach for a semi join when the other table is there only to say which rows count, and for an ordinary join when its columns are wanted as well. (Meridian's view)

KNIME's Reference Row Splitter writes the rows of the first table that are also in a reference table to one output, and all other rows to a second output.[3] Split the rows when both halves are needed, rather than running a keep filter and a drop filter that could be written with different conditions. (Meridian's view)

The method

A semijoin can be simulated with a natural join followed by a projection onto the first table's columns.[1] In E.F. Codd's 1970 paper, the semijoin is called restriction.[1] In DuckDB, semi joins provide the same logic as the IN operator.[2] In PostgreSQL, an EXISTS subquery is generally executed only long enough to determine whether at least one row is returned.[4]

Compared with the alternatives

In PostgreSQL, a WHERE EXISTS test on a key is like an inner join on that key, but produces at most one output row for each outer row, even if several rows match.[4] Where the other table may hold a key more than once, prefer a semi join to an inner join used as a filter. (Meridian's view)

Used in

2 Protocols use this operation: