They each behave differently: it is not a performance choice
The only correct and reliable choice is EXISTS or NOT EXISTS that works all the time.
- JOIN may needs DISTINCT
- WHERE/LEFT JOIN would needs correct placement of the filter
- NOT IN fails on NULL
Example:
DECLARE @Parent TABLE (foo int NULL)
INSERT @Parent (foo) VALUES (1)
INSERT @Parent (foo) VALUES (2)
INSERT @Parent (foo) VALUES (3)
INSERT @Parent (foo) VALUES (4)
DECLARE @Child TABLE (bar int NULL, foo int NULL)
INSERT @Child (bar, foo) VALUES (100, 1)
INSERT @Child (bar, foo) VALUES (200, 2)
INSERT @Child (bar, foo) VALUES (201, 2)
INSERT @Child (bar, foo) VALUES (300, NULL)
INSERT @Child (bar, foo) VALUES (301, NULL)
INSERT @Child (bar, foo) VALUES (400, 4)
INSERT @Child (bar, foo) VALUES (500, NULL)
--"positive" checks
SELECT -- multiple "2" = FAIL without DISTINCT
P.*
FROM
@Parent P JOIN @Child C ON P.foo = C.foo
SELECT -- correct
P.*
FROM
@Parent P
WHERE
P.foo IN (SELECT c.foo FROM @Child C)
SELECT -- correct
P.*
FROM
@Parent P
WHERE
EXISTS (SELECT * FROM @Child C WHERE P.foo = C.foo)
--"negative" checks
SELECT -- correct
P.*
FROM
@Parent P LEFT JOIN @Child C ON P.foo = C.foo
WHERE
C.foo IS NULL
SELECT -- no rows = FAIL
P.*
FROM
@Parent P
WHERE
P.foo NOT IN (SELECT c.foo FROM @Child C)
SELECT -- correct
P.*
FROM
@Parent P
WHERE
NOT EXISTS (SELECT * FROM @Child C WHERE P.foo = C.foo)
Note: with EXISTS, the SELECT in the subquery is irrelevant as mentioned in ANSI 92 standard...
NOT EXISTS (SELECT * FROM @Child C WHERE P.foo = C.foo)
NOT EXISTS (SELECT NULL FROM @Child C WHERE P.foo = C.foo)
NOT EXISTS (SELECT 1 FROM @Child C WHERE P.foo = C.foo)
NOT EXISTS (SELECT 1/0 FROM @Child C WHERE P.foo = C.foo)